Files
gunbuilder-next-tailwind/src/app/(main)/build/page.tsx
2025-07-02 04:16:53 -04:00

769 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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 = [
{
name: 'Upper Parts',
description: 'Components that make up the upper receiver assembly',
components: [
{
id: 'upper-receiver',
name: 'Upper Receiver',
category: 'Upper',
description: 'The upper receiver houses the barrel, bolt carrier group, and charging handle',
required: true,
status: 'pending',
estimatedPrice: 150,
notes: 'Can be purchased as complete upper or stripped'
},
{
id: 'barrel',
name: 'Barrel',
category: 'Upper',
description: 'The barrel determines accuracy and caliber compatibility',
required: true,
status: 'pending',
estimatedPrice: 200,
notes: 'Common lengths: 16", 18", 20"'
},
{
id: 'bolt-carrier-group',
name: 'Bolt Carrier Group (BCG)',
category: 'Upper',
description: 'Handles the firing, extraction, and ejection of rounds',
required: true,
status: 'pending',
estimatedPrice: 150,
notes: 'Mil-spec or enhanced options available'
},
{
id: 'charging-handle',
name: 'Charging Handle',
category: 'Upper',
description: 'Allows manual operation of the bolt carrier group',
required: true,
status: 'pending',
estimatedPrice: 50,
notes: 'Standard or ambidextrous options'
},
{
id: 'gas-block',
name: 'Gas Block',
category: 'Upper',
description: 'Controls gas flow from barrel to BCG',
required: true,
status: 'pending',
estimatedPrice: 30,
notes: 'Low-profile for free-float handguards'
},
{
id: 'gas-tube',
name: 'Gas Tube',
category: 'Upper',
description: 'Transfers gas from barrel to BCG',
required: true,
status: 'pending',
estimatedPrice: 15,
notes: 'Carbine, mid-length, or rifle length'
},
{
id: 'handguard',
name: 'Handguard',
category: 'Upper',
description: 'Provides grip and mounting points for accessories',
required: true,
status: 'pending',
estimatedPrice: 100,
notes: 'Free-float or drop-in options'
},
{
id: 'muzzle-device',
name: 'Muzzle Device',
category: 'Upper',
description: 'Flash hider, compensator, or suppressor mount',
required: true,
status: 'pending',
estimatedPrice: 80,
notes: 'A2 flash hider is standard'
}
]
},
{
name: 'Lower Parts',
description: 'Components that make up the lower receiver assembly',
components: [
{
id: 'lower-receiver',
name: 'Lower Receiver',
category: 'Lower',
description: 'The lower receiver contains the trigger group and magazine well',
required: true,
status: 'pending',
estimatedPrice: 100,
notes: 'Must be purchased through FFL dealer'
},
{
id: 'trigger',
name: 'Trigger',
category: 'Lower',
description: 'Controls firing mechanism',
required: true,
status: 'pending',
estimatedPrice: 60,
notes: 'Mil-spec or enhanced triggers available'
},
{
id: 'trigger-guard',
name: 'Trigger Guard',
category: 'Lower',
description: 'Protects trigger from accidental discharge',
required: true,
status: 'pending',
estimatedPrice: 10,
notes: 'Often included with lower receiver'
},
{
id: 'pistol-grip',
name: 'Pistol Grip',
category: 'Lower',
description: 'Provides grip for firing hand',
required: true,
status: 'pending',
estimatedPrice: 25,
notes: 'Various ergonomic options available'
},
{
id: 'buffer-tube',
name: 'Buffer Tube',
category: 'Lower',
description: 'Houses buffer and spring for recoil management',
required: true,
status: 'pending',
estimatedPrice: 40,
notes: 'Carbine, A5, or rifle length'
},
{
id: 'buffer',
name: 'Buffer',
category: 'Lower',
description: 'Absorbs recoil energy',
required: true,
status: 'pending',
estimatedPrice: 20,
notes: 'H1, H2, H3 weights available'
},
{
id: 'buffer-spring',
name: 'Buffer Spring',
category: 'Lower',
description: 'Returns BCG to battery position',
required: true,
status: 'pending',
estimatedPrice: 15,
notes: 'Standard or enhanced springs'
},
{
id: 'stock',
name: 'Stock',
category: 'Lower',
description: 'Provides shoulder support and cheek weld',
required: true,
status: 'pending',
estimatedPrice: 60,
notes: 'Fixed or adjustable options'
}
]
},
{
name: 'Accessories',
description: 'Additional components needed for a complete build',
components: [
{
id: 'magazine',
name: 'Magazine',
category: 'Accessory',
description: 'Holds and feeds ammunition',
required: true,
status: 'pending',
estimatedPrice: 15,
notes: '30-round capacity is standard'
},
{
id: 'sights',
name: 'Sights',
category: 'Accessory',
description: 'Iron sights or optic for aiming',
required: true,
status: 'pending',
estimatedPrice: 100,
notes: 'Backup iron sights recommended'
}
]
}
];
// Flatten all components for filtering and sorting
const allComponents = buildGroups.flatMap(group => group.components);
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';
};
// 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 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) {
return false;
}
if (searchTerm && !component.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!component.description.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
});
// Sort components
const sortedComponents = [...filteredComponents].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'estimatedPrice') {
aValue = a.estimatedPrice;
bValue = b.estimatedPrice;
} else if (sortField === 'category') {
aValue = a.category.toLowerCase();
bValue = b.category.toLowerCase();
} else if (sortField === 'status') {
aValue = a.status.toLowerCase();
bValue = b.status.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 getStatusColor = (status: string) => {
switch (status) {
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 => 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">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 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>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedCount}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<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 flex flex-col items-center md:flex-row md:justify-center md:items-center gap-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>
<button
className="border border-red-300 hover:bg-red-50 text-red-700 ml-0 md:ml-4 px-4 py-2 rounded-md transition-colors"
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="text-gray-700 hover:bg-gray-100 px-3 py-1 rounded-md text-sm transition-colors"
onClick={() => setShowClearModal(false)}
>
Cancel
</button>
<button
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm transition-colors"
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-3">
{/* Filters Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Category Dropdown */}
<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-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}>
{category}
</option>
))}
</select>
</div>
{/* Status Filter */}
<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-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>
<option value="completed">Completed</option>
</select>
</div>
{/* Sort by */}
<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-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>
<option value="estimatedPrice">Price</option>
<option value="status">Status</option>
</select>
</div>
{/* Clear Filters */}
<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>
</div>
</div>
</div>
{/* 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 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 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
</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('name')}
>
<div className="flex items-center space-x-1">
<span>Component</span>
<span className="text-sm">{getSortIcon('name')}</span>
</div>
</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('category')}
>
<div className="flex items-center space-x-1">
<span>Category</span>
<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 cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('estimatedPrice')}
>
<div className="flex items-center space-x-1">
<span>Price</span>
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Selected Product
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedComponents.length > 0 ? (
buildGroups.map((group) => {
// Filter components in this group that match current filters
const groupComponents = group.components.filter(component =>
sortedComponents.some(sorted => sorted.id === component.id)
);
if (groupComponents.length === 0) return null;
return (
<React.Fragment key={group.name}>
{/* Group Header */}
<tr className="bg-gray-100">
<td colSpan={5} className="px-6 py-2">
<div className="flex items-center">
<div>
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
</div>
<div className="ml-auto text-right">
<div className="text-xs text-gray-500 font-medium">
{groupComponents.length} components
</div>
</div>
</div>
</td>
</tr>
{/* Group Components */}
{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-primary hover:text-blue-800 hover:underline"
>
{selected.name}
</Link>
</div>
<div className="text-xs text-gray-500">
{selected.brand.name} &middot; {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 whitespace-nowrap text-sm font-medium">
{selected ? (
<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(component.id)}
>
Remove
</button>
) : (
<Link
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"
>
Find Parts
</Link>
)}
</td>
</tr>
);
})}
</React.Fragment>
);
})
) : (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<div className="text-gray-500">
<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>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-gray-50 px-6 py-3 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">
Showing {sortedComponents.length} of {allComponents.length} components
{hasActiveFilters && (
<span className="ml-2 text-primary">
(filtered)
</span>
)}
</div>
<div className="text-sm text-gray-500">
Total Value: ${actualTotalCost.toFixed(2)}
</div>
</div>
</div>
</div>
</div>
</main>
);
}