2 Commits

Author SHA1 Message Date
sean 64f288d8f7 styles all fixed 2025-06-29 14:07:26 -04:00
sean 14b25e7359 fixed theming and color 2025-06-29 13:43:46 -04:00
10 changed files with 1208 additions and 790 deletions
+6 -21
View File
@@ -331,27 +331,15 @@ export default function BuildPage() {
{/* Search and Filters */} {/* Search and Filters */}
<div className="bg-white border-b"> <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-4">
{/* Search Row */}
<div className="mb-4 flex justify-end">
<div className="w-1/2">
<SearchInput
label="Search Components"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search components..."
/>
</div>
</div>
{/* Filters Row */} {/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 !bg-white">
{/* Category Dropdown */} {/* Category Dropdown */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label> <label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select <select
value={selectedCategory} value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)} 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" 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"
> >
{categories.map((category) => ( {categories.map((category) => (
<option key={category} value={category}> <option key={category} value={category}>
@@ -364,7 +352,7 @@ export default function BuildPage() {
{/* Status Filter */} {/* Status Filter */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label> <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"> <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">
<option value="all">All Status</option> <option value="all">All Status</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="in-progress">In Progress</option> <option value="in-progress">In Progress</option>
@@ -378,7 +366,7 @@ export default function BuildPage() {
<select <select
value={sortField} value={sortField}
onChange={(e) => handleSort(e.target.value as 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" 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"
> >
<option value="name">Name</option> <option value="name">Name</option>
<option value="category">Category</option> <option value="category">Category</option>
@@ -521,14 +509,11 @@ export default function BuildPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2"> <div className="flex space-x-2">
<Link <Link
href={`/?category=${encodeURIComponent(component.category)}`} href={`/parts?category=${encodeURIComponent(component.category)}`}
className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors" className="bg-[#4B6516] text-white py-1 px-3 rounded text-xs hover:bg-[#3a4e12] transition-colors"
> >
Find Parts Find Parts
</Link> </Link>
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
</button>
</div> </div>
</td> </td>
</tr> </tr>
+21 -21
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import SearchInput from '@/components/SearchInput'; import SearchInput from '@/components/SearchInput';
// Sample build data // Sample build data
const sampleBuilds = [ const sampleMyBuilds = [
{ {
id: '1', id: '1',
name: 'Budget AR-15 Build', name: 'Budget AR-15 Build',
@@ -121,18 +121,18 @@ const sampleBuilds = [
} }
]; ];
type BuildStatus = 'completed' | 'in-progress' | 'planning'; type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate'; type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
export default function BuildsPage() { export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate'); const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc'); const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<BuildStatus | 'all'>('all'); const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
// Filter builds // Filter builds
const filteredBuilds = sampleBuilds.filter(build => { const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) { if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false; return false;
} }
@@ -146,7 +146,7 @@ export default function BuildsPage() {
}); });
// Sort builds // Sort builds
const sortedBuilds = [...filteredBuilds].sort((a, b) => { const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any; let aValue: any, bValue: any;
if (sortField === 'totalCost') { if (sortField === 'totalCost') {
@@ -170,7 +170,7 @@ export default function BuildsPage() {
} }
}); });
const getStatusColor = (status: BuildStatus) => { const getStatusColor = (status: MyBuildStatus) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
return 'bg-green-100 text-green-800'; return 'bg-green-100 text-green-800';
@@ -183,7 +183,7 @@ export default function BuildsPage() {
} }
}; };
const getStatusIcon = (status: BuildStatus) => { const getStatusIcon = (status: MyBuildStatus) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
return '✓'; return '✓';
@@ -204,14 +204,14 @@ export default function BuildsPage() {
}); });
}; };
const getProgressPercentage = (build: typeof sampleBuilds[0]) => { const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100); return Math.round((build.components.completed / build.components.total) * 100);
}; };
const totalBuilds = sampleBuilds.length; const totalMyBuilds = sampleMyBuilds.length;
const completedBuilds = sampleBuilds.filter(build => build.status === 'completed').length; const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressBuilds = sampleBuilds.filter(build => build.status === 'in-progress').length; const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleBuilds.reduce((sum, build) => sum + build.totalCost, 0); const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
return ( return (
<main className="min-h-screen bg-gray-50"> <main className="min-h-screen bg-gray-50">
@@ -228,15 +228,15 @@ export default function BuildsPage() {
<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-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">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-gray-900">{totalBuilds}</div> <div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div> <div className="text-sm text-gray-500">Total Builds</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedBuilds}</div> <div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div> <div className="text-sm text-gray-500">Completed</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressBuilds}</div> <div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div> <div className="text-sm text-gray-500">In Progress</div>
</div> </div>
<div className="text-center"> <div className="text-center">
@@ -254,10 +254,10 @@ export default function BuildsPage() {
<div className="mb-4 flex justify-end"> <div className="mb-4 flex justify-end">
<div className="w-1/2"> <div className="w-1/2">
<SearchInput <SearchInput
label="Search Builds" label="Search My Builds"
value={searchTerm} value={searchTerm}
onChange={setSearchTerm} onChange={setSearchTerm}
placeholder="Search builds..." placeholder="Search my builds..."
/> />
</div> </div>
</div> </div>
@@ -269,7 +269,7 @@ export default function BuildsPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select <select
value={selectedStatus} value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as BuildStatus | 'all')} onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="all">All Status</option> <option value="all">All Status</option>
@@ -319,9 +319,9 @@ export default function BuildsPage() {
{/* Builds Grid */} {/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedBuilds.length > 0 ? ( {sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedBuilds.map((build) => ( {sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden"> <div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */} {/* Build Image */}
<div className="h-48 bg-gray-200 relative"> <div className="h-48 bg-gray-200 relative">
+3 -6
View File
@@ -32,7 +32,7 @@
/* Focus styles for better accessibility */ /* Focus styles for better accessibility */
.focus-ring { .focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900; @apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
} }
/* Card styles */ /* Card styles */
@@ -41,16 +41,13 @@
} }
/* Button styles */ /* Button styles */
.btn-primary { /* Removed custom .btn-primary to avoid DaisyUI conflict */
@apply bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-secondary { .btn-secondary {
@apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring; @apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
} }
/* Input styles */ /* Input styles */
.input-field { .input-field {
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors duration-200; @apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning data-theme="pew">
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} antialiased`}>
<ThemeProvider> <ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200"> <div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
+417
View File
@@ -0,0 +1,417 @@
'use client';
import { useState } from 'react';
import SearchInput from '@/components/SearchInput';
// Sample build data
const sampleMyBuilds = [
{
id: '1',
name: 'Budget AR-15 Build',
description: 'A cost-effective AR-15 build using quality budget components',
status: 'completed' as const,
totalCost: 847.50,
completedDate: '2024-01-15',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Budget', '5.56 NATO', '16" Barrel'],
image: 'https://picsum.photos/400/250?random=1'
},
{
id: '2',
name: 'Precision Long Range',
description: 'High-end precision build optimized for long-range accuracy',
status: 'in-progress' as const,
totalCost: 2847.99,
startedDate: '2024-02-01',
components: {
total: 18,
completed: 12,
categories: {
'Upper': 6,
'Lower': 4,
'Accessory': 2
}
},
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=2'
},
{
id: '3',
name: 'Home Defense Setup',
description: 'Compact AR-15 configured for home defense scenarios',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-03-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
image: 'https://picsum.photos/400/250?random=3'
},
{
id: '4',
name: 'Competition Rifle',
description: 'Lightweight competition build for 3-gun matches',
status: 'completed' as const,
totalCost: 1650.75,
completedDate: '2023-12-10',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Competition', '5.56 NATO', '18" Barrel'],
image: 'https://picsum.photos/400/250?random=4'
},
{
id: '5',
name: 'Suppressed SBR',
description: 'Short-barreled rifle build with suppressor integration',
status: 'in-progress' as const,
totalCost: 1895.25,
startedDate: '2024-01-20',
components: {
total: 18,
completed: 8,
categories: {
'Upper': 4,
'Lower': 3,
'Accessory': 1
}
},
tags: ['SBR', 'Suppressed', '300 BLK'],
image: 'https://picsum.photos/400/250?random=5'
},
{
id: '6',
name: 'Retro M16A1 Clone',
description: 'Faithful reproduction of the classic M16A1 rifle',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-04-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Retro', '5.56 NATO', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=6'
}
];
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc';
export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Filter builds
const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false;
}
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
});
// Sort builds
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'totalCost') {
aValue = a.totalCost;
bValue = b.totalCost;
} else if (sortField === 'status') {
aValue = a.status;
bValue = b.status;
} else if (sortField === 'completedDate') {
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const getStatusColor = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
case 'planning':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return '✓';
case 'in-progress':
return '🔄';
case 'planning':
return '📋';
default:
return '❓';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100);
};
const totalMyBuilds = sampleMyBuilds.length;
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
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">My Builds</h1>
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
</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="text-center">
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Value</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">
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="in-progress">In Progress</option>
<option value="planning">Planning</option>
</select>
</div>
{/* Sort by */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
<select
value={sortField}
onChange={(e) => setSortField(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"
>
<option value="completedDate">Date</option>
<option value="name">Name</option>
<option value="totalCost">Cost</option>
<option value="status">Status</option>
</select>
</div>
{/* Sort Direction */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
<select
value={sortDirection}
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
</div>
{/* New Build Button */}
<div className="flex items-end">
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
+ New Build
</button>
</div>
</div>
</div>
</div>
{/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */}
<div className="h-48 bg-gray-200 relative">
<img
src={build.image}
alt={build.name}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
<div className="text-center text-gray-600">
<div className="text-4xl mb-2">🔫</div>
<div className="text-sm font-medium">{build.name}</div>
</div>
</div>
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
{getStatusIcon(build.status)} {build.status}
</span>
</div>
</div>
{/* Build Content */}
<div className="p-6">
{/* Build Title and Date */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
<div className="text-xs text-gray-500">
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress</span>
<span>{build.components.completed}/{build.components.total} components</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getProgressPercentage(build)}%` }}
></div>
</div>
</div>
{/* Component Categories */}
<div className="mb-4">
<div className="flex space-x-2">
{Object.entries(build.components.categories).map(([category, count]) => (
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
{category}: {count}
</span>
))}
</div>
</div>
{/* Tags */}
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{build.tags.map((tag) => (
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
{tag}
</span>
))}
</div>
</div>
{/* Cost and Actions */}
<div className="flex justify-between items-center">
<div className="text-lg font-bold text-gray-900">
${build.totalCost.toFixed(2)}
</div>
<div className="flex space-x-2">
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
View Details
</button>
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
Edit
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-lg font-medium mb-2">No builds found</div>
<div className="text-sm">Try adjusting your filters or create a new build</div>
</div>
</div>
)}
</div>
</main>
);
}
+53 -635
View File
@@ -1,640 +1,58 @@
'use client'; import BetaTester from "../components/BetaTester";
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/20/solid';
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 Link from 'next/link';
import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
// 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 = [
'',
'NFA',
'SBR',
'SUPPRESSOR',
'STATE_RESTRICTIONS',
];
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;
export default function LandingPage() {
return ( return (
<div <div className="bg-white font-sans">
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`} {/* SVG Grid Background */}
title={config.tooltip} <div className="relative isolate pt-1">
> <svg
<span>{config.icon}</span> aria-hidden="true"
<span>{config.label}</span> className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
>
<defs>
<pattern
id="grid"
width={200}
height={200}
patternUnits="userSpaceOnUse"
>
<path d="M100 200V.5M.5 .5H200" fill="none" />
</pattern>
</defs>
<rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-start lg:gap-x-10 lg:px-8 lg:py-40">
{/* Left: Headline, Subheading, Button */}
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
<h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
A better way to plan your next build
</h1>
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
</p>
<div className="mt-10 flex items-top gap-x-6">
<Link
href="/Builder"
className="btn btn-primary text-base font-semibold px-6"
>
Get Building
</Link>
</div>
</div>
{/* Right: Product Image */}
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center">
<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"
/>
</div>
</div>
</div>
{/* Beta Tester CTA */}
<BetaTester />
</div> </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>
<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">
<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"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
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"
>
<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">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
export default function Home() {
const searchParams = useSearchParams();
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');
// 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;
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;
}
// 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');
if (restrictions?.suppressor) flags.push('SUPPRESSOR');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('STATE_RESTRICTIONS');
return flags;
};
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">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* 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>
</div>
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Category Dropdown */}
<Dropdown
label="Category"
value={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
{/* Brand Dropdown */}
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
{/* Vendor Dropdown */}
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
{/* Price Range */}
<div className="relative">
<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">
<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
className="h-5 w-5 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-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"
>
<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">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
{/* Clear Filters */}
<div className="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 ${
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" />
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')}
>
Table
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
>
Cards
</button>
</div>
</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">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</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('name')}
>
<div className="flex items-center space-x-1">
<span>Name</span>
<span className="text-sm">{getSortIcon('name')}</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">
Brand
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Description
</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">
<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-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-neutral-900 dark:text-white">
{part.name}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
{part.brand.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-neutral-900 dark:text-white">
{part.brand.name}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
{part.description}
</div>
</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">
<Link href={`/products/${part.id}`} legacyBehavior>
<a className="btn btn-primary btn-sm">
View Details
</a>
</Link>
</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) => (
<ProductCard key={part.id} product={part} />
))}
</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>
);
}
+622
View File
@@ -0,0 +1,622 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon } from '@heroicons/react/20/solid';
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';
import Image from 'next/image';
// 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 = [
'',
'NFA',
'SBR',
'SUPPRESSOR',
'STATE_RESTRICTIONS',
];
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>
<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">
<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"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
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"
>
<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">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
export default function Home() {
const searchParams = useSearchParams();
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');
// 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;
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;
}
// 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');
if (restrictions?.suppressor) flags.push('SUPPRESSOR');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('STATE_RESTRICTIONS');
return flags;
};
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">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* 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>
</div>
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Category Dropdown */}
<Dropdown
label="Category"
value={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
{/* Brand Dropdown */}
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
{/* Vendor Dropdown */}
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
{/* Price Range */}
<div className="relative">
<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">
<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
className="h-5 w-5 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-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"
>
<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">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
{/* Clear Filters */}
<div className="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 ${
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" />
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')}
aria-label="Table view"
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
aria-label="Card view"
>
<Squares2X2Icon className="h-5 w-5" />
</button>
</div>
</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">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</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">
<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" />
</div>
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{part.name}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</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-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</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">
<Link href={`/products/${part.id}`} legacyBehavior>
<a className="btn btn-primary btn-sm">
Add
</a>
</Link>
</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) => (
<ProductCard key={part.id} product={part} />
))}
</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>
);
}
+40
View File
@@ -0,0 +1,40 @@
export default function BetaTester() {
return (
<div className="bg-gray-900 py-16 sm:py-24 lg:py-32">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 lg:grid-cols-12 lg:gap-8 lg:px-8">
<h2 className="max-w-xl text-balance text-3xl font-semibold tracking-tight text-white sm:text-4xl lg:col-span-7">
Interested in being a beta tester? Join the beta tester list.
</h2>
<form className="w-full max-w-md lg:col-span-5 lg:pt-2">
<div className="flex gap-x-4">
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
required
placeholder="Enter your email"
autoComplete="email"
className="min-w-0 flex-auto rounded-md bg-white/5 px-3.5 py-2 text-base text-white outline outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
/>
<button
type="submit"
className="flex-none rounded-md bg-primary px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Join The List
</button>
</div>
<p className="mt-4 text-sm/6 text-gray-300">
We care about your data. Read our{' '}
<a href="#" className="font-semibold text-white">
privacy&nbsp;policy
</a>
.
</p>
</form>
</div>
</div>
)
}
+27 -54
View File
@@ -3,42 +3,38 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import ThemeSwitcher from './ThemeSwitcher'; import ThemeSwitcher from './ThemeSwitcher';
import { MagnifyingGlassIcon, UserCircleIcon } from '@heroicons/react/24/outline';
export default function Navbar() { export default function Navbar() {
const pathname = usePathname(); const pathname = usePathname();
const navItems = [ const navItems = [
{ href: '/', label: 'Parts Catalog' }, { href: '/parts', label: 'Parts Catalog' },
{ href: '/build', label: 'Build Checklist' }, { href: '/build', label: 'Plan a Build' },
{ href: '/builds', label: 'My Builds' }, { href: '/my-builds', label: 'My Builds' },
]; ];
return ( return (
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700 shadow-sm"> <>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* Top Bar */}
<div className="flex justify-between items-center h-16"> <div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8">
{/* Logo */} <span className="font-bold text-lg tracking-tight">Pew Builder</span>
<div className="flex items-center"> <UserCircleIcon className="h-7 w-7 text-white opacity-80" />
<Link href="/" className="flex items-center space-x-2"> </div>
<div className="w-8 h-8 bg-primary-600 dark:bg-primary-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">🔫</span>
</div>
<span className="text-xl font-bold text-neutral-900 dark:text-white">
Pew Builder
</span>
</Link>
</div>
{/* Navigation Links */} {/* Subnav */}
<div className="hidden md:flex items-center space-x-8"> <nav 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 flex justify-between items-center h-14">
{/* Left: Nav Links */}
<div className="flex items-center space-x-6">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
pathname === item.href pathname === item.href
? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' ? 'text-primary font-semibold underline underline-offset-4'
: 'text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
}`} }`}
> >
{item.label} {item.label}
@@ -46,41 +42,18 @@ export default function Navbar() {
))} ))}
</div> </div>
{/* Theme Switcher */} {/* Right: Sign In + Search */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<ThemeSwitcher /> <Link href="/signin" className="btn btn-sm btn-ghost text-sm font-medium">
Sign In
{/* Mobile menu button */} </Link>
<button className="md:hidden p-2 rounded-md text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700"> <button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button> </button>
<ThemeSwitcher />
</div> </div>
</div> </div>
</div> </nav>
</>
{/* Mobile menu */}
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.label}
</Link>
);
})}
</div>
</div>
</nav>
); );
} }
+18 -52
View File
@@ -10,20 +10,6 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Primary brand colors
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
// Secondary accent colors // Secondary accent colors
accent: { accent: {
50: '#fef2f2', 50: '#fef2f2',
@@ -105,50 +91,30 @@ module.exports = {
daisyui: { daisyui: {
themes: [ themes: [
{ {
light: { pew: {
"primary": "#0ea5e9", primary: '#4B6516', // Olive/army green
"primary-content": "#ffffff", 'primary-content': '#fff',
"secondary": "#ef4444", accent: '#181C20', // Dark navy for CTA/footer
"secondary-content": "#ffffff", 'accent-content': '#fff',
"accent": "#f59e0b", neutral: '#222',
"accent-content": "#ffffff", 'base-100': '#fff',
"neutral": "#737373", 'base-200': '#f5f6fa',
"neutral-content": "#ffffff", 'base-300': '#e5e7eb',
"base-100": "#ffffff", info: '#3ABFF8',
"base-200": "#f5f5f5", success: '#36D399',
"base-300": "#e5e5e5", warning: '#FBBD23',
"base-content": "#171717", error: '#F87272',
"info": "#0ea5e9",
"success": "#22c55e",
"warning": "#f59e0b",
"error": "#ef4444",
},
dark: {
"primary": "#38bdf8",
"primary-content": "#ffffff",
"secondary": "#f87171",
"secondary-content": "#ffffff",
"accent": "#fbbf24",
"accent-content": "#ffffff",
"neutral": "#a3a3a3",
"neutral-content": "#ffffff",
"base-100": "#171717",
"base-200": "#262626",
"base-300": "#404040",
"base-content": "#ffffff",
"info": "#38bdf8",
"success": "#4ade80",
"warning": "#fbbf24",
"error": "#f87171",
}, },
}, },
'dark',
], ],
darkTheme: "dark", darkTheme: "dark",
base: true, base: true,
styled: true, styled: true,
utils: true, utils: true,
prefix: "", logs: false,
logs: true, rtl: false,
themeRoot: ":root", prefix: '',
// 'pew' is the default theme
}, },
}; };