mirror of
https://gitea.gofwd.group/sean/gunbuilder-next-tailwind.git
synced 2025-12-06 02:56:45 -05:00
add state management and other stuff
This commit is contained in:
@@ -2,14 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
remotePatterns: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
"daisyui": "^4.7.3",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -1258,7 +1259,7 @@
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2545,7 +2546,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/culori": {
|
||||
@@ -6826,6 +6827,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
|
||||
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"daisyui": "^4.7.3",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||
import { useBuildStore } from '@/store/useBuildStore';
|
||||
import { mockProducts } from '@/mock/product';
|
||||
import { Dialog } from '@headlessui/react';
|
||||
|
||||
// AR-15 Build Requirements grouped by main categories
|
||||
const buildGroups = [
|
||||
@@ -215,12 +219,60 @@ const categories = ["All", "Upper", "Lower", "Accessory"];
|
||||
type SortField = 'name' | 'category' | 'estimatedPrice' | 'status';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
// Map checklist component categories to product categories for filtering
|
||||
const getProductCategory = (componentCategory: string): string => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Upper': 'Upper Receiver', // Default to Upper Receiver for Upper category
|
||||
'Lower': 'Lower Receiver', // Default to Lower Receiver for Lower category
|
||||
'Accessory': 'Magazine', // Default to Magazine for Accessory category
|
||||
};
|
||||
|
||||
return categoryMap[componentCategory] || 'Magazine';
|
||||
};
|
||||
|
||||
// Map specific checklist components to product categories
|
||||
const getProductCategoryForComponent = (componentName: string): string => {
|
||||
const componentMap: Record<string, string> = {
|
||||
// Upper components
|
||||
'Upper Receiver': 'Upper Receiver',
|
||||
'Barrel': 'Barrel',
|
||||
'Bolt Carrier Group (BCG)': 'BCG',
|
||||
'Charging Handle': 'Charging Handle',
|
||||
'Gas Block': 'Gas Block',
|
||||
'Gas Tube': 'Gas Tube',
|
||||
'Handguard': 'Handguard',
|
||||
'Muzzle Device': 'Muzzle Device',
|
||||
|
||||
// Lower components
|
||||
'Lower Receiver': 'Lower Receiver',
|
||||
'Trigger': 'Trigger',
|
||||
'Trigger Guard': 'Lower Receiver',
|
||||
'Pistol Grip': 'Lower Receiver',
|
||||
'Buffer Tube': 'Lower Receiver',
|
||||
'Buffer': 'Lower Receiver',
|
||||
'Buffer Spring': 'Lower Receiver',
|
||||
'Stock': 'Stock',
|
||||
|
||||
// Accessories
|
||||
'Magazine': 'Magazine',
|
||||
'Sights': 'Magazine',
|
||||
};
|
||||
|
||||
return componentMap[componentName] || 'Lower Receiver';
|
||||
};
|
||||
|
||||
export { buildGroups };
|
||||
export default function BuildPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const selectedParts = useBuildStore((state) => state.selectedParts);
|
||||
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
||||
const clearBuild = useBuildStore((state) => state.clearBuild);
|
||||
const [showClearModal, setShowClearModal] = useState(false);
|
||||
|
||||
// Filter components
|
||||
const filteredComponents = allComponents.filter(component => {
|
||||
if (selectedCategory !== 'All' && component.category !== selectedCategory) {
|
||||
@@ -278,36 +330,66 @@ export default function BuildPage() {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'in-progress':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'in-progress': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'pending': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
|
||||
const completedCount = sortedComponents.filter(component => component.status === 'completed').length;
|
||||
const completedCount = sortedComponents.filter(component => selectedParts[component.id]).length;
|
||||
const actualTotalCost = sortedComponents.reduce((sum, component) => {
|
||||
const selected = selectedParts[component.id];
|
||||
if (selected && selected.offers) {
|
||||
return sum + Math.min(...selected.offers.map(offer => offer.price));
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
|
||||
|
||||
// Check for restricted parts in the build
|
||||
const getRestrictedParts = () => {
|
||||
const restrictedParts: Array<{ part: any; restriction: string }> = [];
|
||||
|
||||
Object.values(selectedParts).forEach(selectedPart => {
|
||||
if (selectedPart) {
|
||||
const product = mockProducts.find(p => p.id === selectedPart.id);
|
||||
if (product?.restrictions) {
|
||||
const restrictions = product.restrictions;
|
||||
if (restrictions.nfa) restrictedParts.push({ part: product, restriction: 'NFA' });
|
||||
if (restrictions.sbr) restrictedParts.push({ part: product, restriction: 'SBR' });
|
||||
if (restrictions.suppressor) restrictedParts.push({ part: product, restriction: 'Suppressor' });
|
||||
if (restrictions.stateRestrictions && restrictions.stateRestrictions.length > 0) {
|
||||
restrictedParts.push({ part: product, restriction: 'State Restrictions' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return restrictedParts;
|
||||
};
|
||||
|
||||
const restrictedParts = getRestrictedParts();
|
||||
const hasNFAItems = restrictedParts.some(rp => rp.restriction === 'NFA');
|
||||
const hasSuppressors = restrictedParts.some(rp => rp.restriction === 'Suppressor');
|
||||
const hasStateRestrictions = restrictedParts.some(rp => rp.restriction === 'State Restrictions');
|
||||
const [showRestrictionAlerts, setShowRestrictionAlerts] = useState(true);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Page Title */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">AR-15 Build Checklist</h1>
|
||||
<p className="text-gray-600 mt-2">Track your build progress and find required components</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Plan Your Build</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Summary */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{allComponents.length}</div>
|
||||
<div className="text-sm text-gray-500">Total Components</div>
|
||||
@@ -320,26 +402,138 @@ export default function BuildPage() {
|
||||
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
|
||||
<div className="text-sm text-gray-500">Remaining</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">${totalEstimatedCost}</div>
|
||||
<div className="text-sm text-gray-500">Estimated Cost</div>
|
||||
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-500">Total Cost</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-error ml-0 md:ml-4"
|
||||
onClick={() => setShowClearModal(true)}
|
||||
>
|
||||
Clear Build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Build Modal */}
|
||||
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
|
||||
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
|
||||
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
|
||||
<Dialog.Description className="mb-4 text-gray-600">
|
||||
Are you sure you want to clear your entire build? This action cannot be undone.
|
||||
</Dialog.Description>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => setShowClearModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-error"
|
||||
onClick={() => {
|
||||
clearBuild();
|
||||
setShowClearModal(false);
|
||||
}}
|
||||
>
|
||||
Yes, Clear Build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Restriction Alerts */}
|
||||
{restrictedParts.length > 0 && (
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRestrictionAlerts(!showRestrictionAlerts)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||
>
|
||||
{showRestrictionAlerts ? 'Hide' : 'Show'} details
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showRestrictionAlerts ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRestrictionAlerts && (
|
||||
<div className="space-y-2">
|
||||
{hasNFAItems && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🔒</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">NFA Items in Your Build</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Your build contains items that require National Firearms Act registration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSuppressors && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🔇</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">Suppressor in Your Build</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Sound suppressor requires NFA registration. Processing times: 6-12 months.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasStateRestrictions && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🗺️</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">State Restrictions Apply</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Some items may be restricted in certain states. Verify local laws.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 !bg-white">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Category Dropdown */}
|
||||
<div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900"
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
@@ -350,9 +544,9 @@ export default function BuildPage() {
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900">
|
||||
<select className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm">
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
@@ -361,12 +555,12 @@ export default function BuildPage() {
|
||||
</div>
|
||||
|
||||
{/* Sort by */}
|
||||
<div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
||||
<select
|
||||
value={sortField}
|
||||
onChange={(e) => handleSort(e.target.value as SortField)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900"
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="category">Category</option>
|
||||
@@ -376,8 +570,8 @@ export default function BuildPage() {
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<div className="col-span-1 flex items-end">
|
||||
<button className="w-full px-3 py-1.5 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
@@ -387,10 +581,10 @@ export default function BuildPage() {
|
||||
|
||||
{/* Build Components Table */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
||||
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
@@ -413,15 +607,12 @@ export default function BuildPage() {
|
||||
<span className="text-sm">{getSortIcon('category')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('estimatedPrice')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Est. Price</span>
|
||||
<span>Price</span>
|
||||
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
||||
</div>
|
||||
</th>
|
||||
@@ -429,7 +620,7 @@ export default function BuildPage() {
|
||||
Notes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
Selected Product
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -446,78 +637,103 @@ export default function BuildPage() {
|
||||
return (
|
||||
<React.Fragment key={group.name}>
|
||||
{/* Group Header */}
|
||||
<tr className="bg-gray-800">
|
||||
<td colSpan={7} className="px-6 py-4">
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={7} className="px-6 py-2">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-semibold text-lg">
|
||||
{group.name === 'Upper Parts' ? '🔫' :
|
||||
group.name === 'Lower Parts' ? '🔧' : '📦'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-xl font-bold text-white">{group.name}</h3>
|
||||
<p className="text-gray-300">{group.description}</p>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-gray-300 font-medium">
|
||||
<div className="text-xs text-gray-500 font-medium">
|
||||
{groupComponents.length} components
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Group Components */}
|
||||
{groupComponents.map((component) => (
|
||||
<tr key={component.id} className="hover:bg-gray-50">
|
||||
<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 ${getStatusColor(component.status)}`}>
|
||||
{component.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{component.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{component.required ? 'Required' : 'Optional'}
|
||||
</div>
|
||||
</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">
|
||||
{component.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-500 max-w-xs">
|
||||
{component.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
${component.estimatedPrice}
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/parts?category=${encodeURIComponent(component.category)}`}
|
||||
className="bg-[#4B6516] text-white py-1 px-3 rounded text-xs hover:bg-[#3a4e12] transition-colors"
|
||||
>
|
||||
Find Parts
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{groupComponents.map((component) => {
|
||||
const selected = selectedParts[component.id];
|
||||
return (
|
||||
<tr key={component.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{selected ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Selected
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
|
||||
{component.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{selected ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<Link
|
||||
href={`/products/${selected.id}`}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{selected.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{selected.brand.name} · {component.required ? 'Required' : 'Optional'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{component.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{component.required ? 'Required' : 'Optional'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
{getProductCategoryForComponent(component.name)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{selected ? (
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
${Math.min(...selected.offers?.map(offer => offer.price) || [0]).toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
{selected ? (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => removePartForComponent(component.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Find Parts
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
@@ -547,7 +763,7 @@ export default function BuildPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)}
|
||||
Total Value: ${actualTotalCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="mt-10 flex items-top gap-x-6">
|
||||
<Link
|
||||
href="/Builder"
|
||||
href="/build"
|
||||
className="btn btn-primary text-base font-semibold px-6"
|
||||
>
|
||||
Get Building
|
||||
@@ -46,7 +46,7 @@ export default function LandingPage() {
|
||||
<img
|
||||
alt="AR-15 Lower Receiver"
|
||||
src="https://i.imgur.com/IK8FbaI.png"
|
||||
className="max-w-md w-full h-auto object-contain rounded-xl shadow-lg"
|
||||
className="max-w-md w-full h-auto object-contain rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon } from '@heroicons/react/20/solid';
|
||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||
@@ -12,6 +12,8 @@ import Link from 'next/link';
|
||||
import { mockProducts } from '@/mock/product';
|
||||
import type { Product } from '@/mock/product';
|
||||
import Image from 'next/image';
|
||||
import { useBuildStore } from '@/store/useBuildStore';
|
||||
import { buildGroups } from '../build/page';
|
||||
|
||||
// Extract unique values for dropdowns
|
||||
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
|
||||
@@ -20,11 +22,11 @@ const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.
|
||||
|
||||
// Restrictions for filter dropdown
|
||||
const restrictionOptions = [
|
||||
'',
|
||||
'All',
|
||||
'NFA',
|
||||
'SBR',
|
||||
'SUPPRESSOR',
|
||||
'STATE_RESTRICTIONS',
|
||||
'Suppressor',
|
||||
'State Restrictions',
|
||||
];
|
||||
|
||||
type SortField = 'name' | 'category' | 'price';
|
||||
@@ -112,13 +114,13 @@ const Dropdown = ({
|
||||
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{label}
|
||||
</Listbox.Label>
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 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">
|
||||
<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">
|
||||
<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
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
className="h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
@@ -128,7 +130,7 @@ const Dropdown = ({
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute z-10 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"
|
||||
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"
|
||||
>
|
||||
<Listbox.Options>
|
||||
{options.map((option, optionIdx) => (
|
||||
@@ -148,7 +150,7 @@ const Dropdown = ({
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
@@ -163,8 +165,78 @@ const Dropdown = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 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] || '';
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
const [selectedBrand, setSelectedBrand] = useState('All');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All');
|
||||
@@ -174,6 +246,11 @@ export default function Home() {
|
||||
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);
|
||||
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
||||
const selectedParts = useBuildStore((state) => state.selectedParts);
|
||||
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
||||
|
||||
// Read category from URL parameter on page load
|
||||
useEffect(() => {
|
||||
@@ -198,8 +275,8 @@ export default function Home() {
|
||||
if (selectedRestriction) {
|
||||
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
|
||||
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
|
||||
else if (selectedRestriction === 'SUPPRESSOR') matchesRestriction = !!part.restrictions?.suppressor;
|
||||
else if (selectedRestriction === 'STATE_RESTRICTIONS') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
|
||||
else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
|
||||
else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
|
||||
else matchesRestriction = false;
|
||||
}
|
||||
|
||||
@@ -280,11 +357,16 @@ export default function Home() {
|
||||
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');
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
|
||||
{/* Page Title */}
|
||||
@@ -309,56 +391,86 @@ export default function Home() {
|
||||
|
||||
{/* Search and Filters */}
|
||||
<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-4">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
{/* Search Row */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<SearchInput
|
||||
label="Search"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search parts..."
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
|
||||
{/* Category Dropdown */}
|
||||
<Dropdown
|
||||
label="Category"
|
||||
value={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
options={categories}
|
||||
placeholder="All categories"
|
||||
/>
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Category"
|
||||
value={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
options={categories}
|
||||
placeholder="All categories"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Brand Dropdown */}
|
||||
<Dropdown
|
||||
label="Brand"
|
||||
value={selectedBrand}
|
||||
onChange={setSelectedBrand}
|
||||
options={brands}
|
||||
placeholder="All brands"
|
||||
/>
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Brand"
|
||||
value={selectedBrand}
|
||||
onChange={setSelectedBrand}
|
||||
options={brands}
|
||||
placeholder="All brands"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor Dropdown */}
|
||||
<Dropdown
|
||||
label="Vendor"
|
||||
value={selectedVendor}
|
||||
onChange={setSelectedVendor}
|
||||
options={vendors}
|
||||
placeholder="All vendors"
|
||||
/>
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Vendor"
|
||||
value={selectedVendor}
|
||||
onChange={setSelectedVendor}
|
||||
options={vendors}
|
||||
placeholder="All vendors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="relative">
|
||||
<div className="col-span-1">
|
||||
<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>
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 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">
|
||||
<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">
|
||||
<span className="block truncate text-neutral-900 dark:text-white">
|
||||
{priceRange === '' ? 'Select price range' :
|
||||
priceRange === 'under-100' ? 'Under $100' :
|
||||
@@ -368,7 +480,7 @@ export default function Home() {
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
className="h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
@@ -378,7 +490,7 @@ export default function Home() {
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute z-10 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"
|
||||
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"
|
||||
>
|
||||
<Listbox.Options>
|
||||
{[
|
||||
@@ -404,7 +516,7 @@ export default function Home() {
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
@@ -418,26 +530,28 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Restriction Filter */}
|
||||
<Dropdown
|
||||
label="Restriction"
|
||||
value={selectedRestriction}
|
||||
onChange={setSelectedRestriction}
|
||||
options={restrictionOptions}
|
||||
placeholder="All restrictions"
|
||||
/>
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Restriction"
|
||||
value={selectedRestriction}
|
||||
onChange={setSelectedRestriction}
|
||||
options={restrictionOptions}
|
||||
placeholder="All restrictions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<div className="col-span-1 flex items-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
|
||||
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 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'
|
||||
}`}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<XMarkIcon className="h-3.5 w-3.5" />
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
@@ -480,24 +594,12 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restriction Alert Example */}
|
||||
{sortedParts.some(part => part.restrictions?.nfa) && (
|
||||
<div className="mb-6">
|
||||
<RestrictionAlert
|
||||
type="warning"
|
||||
title="NFA Items Detected"
|
||||
message="Some items in your search require National Firearms Act registration. Please ensure compliance with all federal and state regulations."
|
||||
icon="🔒"
|
||||
/>
|
||||
</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">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
||||
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-700">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Product
|
||||
@@ -527,7 +629,9 @@ export default function Home() {
|
||||
<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>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{part.name}</div>
|
||||
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
|
||||
{part.name}
|
||||
</Link>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -542,11 +646,50 @@ export default function Home() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<Link href={`/products/${part.id}`} legacyBehavior>
|
||||
<a className="btn btn-primary btn-sm">
|
||||
Add
|
||||
</a>
|
||||
</Link>
|
||||
{(() => {
|
||||
// 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
|
||||
className="btn btn-primary btn-sm"
|
||||
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');
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-xs text-gray-400">Part Selected</span>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -577,7 +720,7 @@ export default function Home() {
|
||||
{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) => (
|
||||
<ProductCard key={part.id} product={part} />
|
||||
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mockProducts } from '@/mock/product';
|
||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||
import { StarIcon } from '@heroicons/react/20/solid';
|
||||
import Image from 'next/image';
|
||||
import { useBuildStore } from '@/store/useBuildStore';
|
||||
|
||||
export default function ProductDetailsPage() {
|
||||
const params = useParams();
|
||||
@@ -14,6 +15,8 @@ export default function ProductDetailsPage() {
|
||||
const product = mockProducts.find(p => p.id === productId);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [selectedOffer, setSelectedOffer] = useState(0);
|
||||
const [addSuccess, setAddSuccess] = useState(false);
|
||||
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
@@ -35,14 +38,51 @@ export default function ProductDetailsPage() {
|
||||
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
|
||||
: 0;
|
||||
|
||||
const handleAddToBuild = () => {
|
||||
// Map category to component ID
|
||||
const categoryToComponentMap: Record<string, string> = {
|
||||
'Barrel': 'barrel',
|
||||
'Upper Receiver': 'upper',
|
||||
'Suppressor': 'suppressor',
|
||||
'BCG': 'bcg',
|
||||
'Charging Handle': 'charging-handle',
|
||||
'Handguard': 'handguard',
|
||||
'Gas Block': 'gas-block',
|
||||
'Gas Tube': 'gas-tube',
|
||||
'Muzzle Device': 'muzzle-device',
|
||||
'Lower Receiver': 'lower',
|
||||
'Trigger': 'trigger',
|
||||
'Pistol Grip': 'pistol-grip',
|
||||
'Buffer Tube': 'buffer-tube',
|
||||
'Buffer': 'buffer',
|
||||
'Buffer Spring': 'buffer-spring',
|
||||
'Stock': 'stock',
|
||||
'Magazine': 'magazine',
|
||||
'Sights': 'sights'
|
||||
};
|
||||
|
||||
const componentId = categoryToComponentMap[product.category.name] || product.category.id;
|
||||
|
||||
selectPartForComponent(componentId, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
image_url: product.image_url,
|
||||
brand: product.brand,
|
||||
category: product.category,
|
||||
offers: product.offers
|
||||
});
|
||||
setAddSuccess(true);
|
||||
setTimeout(() => setAddSuccess(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="text-sm breadcrumbs mb-6">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/products">Products</a></li>
|
||||
<li><a href={`/products?category=${product.category.id}`}>{product.category.name}</a></li>
|
||||
<li><a href="/parts">Parts</a></li>
|
||||
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
|
||||
<li>{product.name}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -112,11 +152,8 @@ export default function ProductDetailsPage() {
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{product.brand.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{product.category.icon}</span>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{product.category.name}
|
||||
</span>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{product.category.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,13 +209,16 @@ export default function ProductDetailsPage() {
|
||||
|
||||
{/* Add to Build Button */}
|
||||
<div className="flex gap-4">
|
||||
<button className="btn btn-primary flex-1">
|
||||
<button className="btn btn-primary flex-1" onClick={handleAddToBuild}>
|
||||
Add to Current Build
|
||||
</button>
|
||||
<button className="btn btn-outline">
|
||||
Save for Later
|
||||
</button>
|
||||
</div>
|
||||
{addSuccess && (
|
||||
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Navbar() {
|
||||
<>
|
||||
{/* Top Bar */}
|
||||
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8">
|
||||
<span className="font-bold text-lg tracking-tight">Pew Builder</span>
|
||||
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
|
||||
<UserCircleIcon className="h-7 w-7 text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Product } from '@/mock/product';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
onAdd?: () => void;
|
||||
added?: boolean;
|
||||
}
|
||||
|
||||
function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
|
||||
@@ -17,7 +19,7 @@ function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
|
||||
return flags;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
export default function ProductCard({ product, onAdd, added }: ProductCardProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
|
||||
|
||||
@@ -86,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
|
||||
<figure className="relative">
|
||||
<img
|
||||
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
|
||||
src={imageError ? '/window.svg' : product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-48 object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
@@ -116,6 +118,15 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
View Details
|
||||
</a>
|
||||
</Link>
|
||||
{onAdd && (
|
||||
<button
|
||||
className="btn btn-accent btn-sm ml-2"
|
||||
onClick={onAdd}
|
||||
disabled={added}
|
||||
>
|
||||
{added ? 'Added!' : 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1301
src/mock/product.ts
1301
src/mock/product.ts
File diff suppressed because it is too large
Load Diff
53
src/store/useBuildStore.ts
Normal file
53
src/store/useBuildStore.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { create, StateCreator } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface BuildPart {
|
||||
id: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
brand: {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
};
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
offers: Array<{
|
||||
price: number;
|
||||
url: string;
|
||||
vendor: {
|
||||
name: string;
|
||||
logo?: string;
|
||||
};
|
||||
inStock?: boolean;
|
||||
shipping?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BuildState {
|
||||
selectedParts: Record<string, BuildPart | null>; // key: checklist component id
|
||||
selectPartForComponent: (componentId: string, part: BuildPart) => void;
|
||||
removePartForComponent: (componentId: string) => void;
|
||||
clearBuild: () => void;
|
||||
}
|
||||
|
||||
const buildStoreCreator: StateCreator<BuildState> = (set) => ({
|
||||
selectedParts: {},
|
||||
selectPartForComponent: (componentId, part) => set((state) => ({
|
||||
selectedParts: { ...state.selectedParts, [componentId]: part },
|
||||
})),
|
||||
removePartForComponent: (componentId) => set((state) => {
|
||||
const updated = { ...state.selectedParts };
|
||||
delete updated[componentId];
|
||||
return { selectedParts: updated };
|
||||
}),
|
||||
clearBuild: () => set({ selectedParts: {} }),
|
||||
});
|
||||
|
||||
export const useBuildStore = create<BuildState>()(
|
||||
persist(buildStoreCreator, {
|
||||
name: 'current-build-storage',
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user