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>
);
}
+40 -622
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>
<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" aria-hidden="true"
/> className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
</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> <defs>
{options.map((option, optionIdx) => ( <pattern
<Listbox.Option id="grid"
key={optionIdx} width={200}
className={({ active }) => height={200}
`relative cursor-default select-none py-2 pl-10 pr-4 ${ patternUnits="userSpaceOnUse"
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option}
> >
{({ selected }) => ( <path d="M100 200V.5M.5 .5H200" fill="none" />
<> </pattern>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> </defs>
{option} <rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
</span> </svg>
{selected ? ( <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">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400"> {/* Left: Headline, Subheading, Button */}
<CheckIcon className="h-5 w-5" aria-hidden="true" /> <div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
</span> <h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
) : null} A better way to plan your next build
</>
)}
</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> </h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2"> <p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
{selectedCategory !== 'All' Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
? `Showing ${selectedCategory} parts for your build` fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
: 'Browse and filter firearm parts for your build'
}
</p> </p>
</div> <div className="mt-10 flex items-top gap-x-6">
</div> <Link
href="/Builder"
{/* Search and Filters */} className="btn btn-primary text-base font-semibold px-6"
<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> Get Building
{[
{ 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> </Link>
</td>
</tr>
))}
</tbody>
</table>
</div> </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>
<div className="text-sm text-neutral-500 dark:text-neutral-400"> {/* Right: Product Image */}
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)} <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> </div>
</div> </div>
{/* Beta Tester CTA */}
<BetaTester />
</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>
)
}
+25 -52
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 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> </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 */}
<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">
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
{/* 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> </Link>
); <button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
})} <MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
</button>
<ThemeSwitcher />
</div> </div>
</div> </div>
</nav> </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
}, },
}; };