mirror of
https://gitea.gofwd.group/sean/gunbuilder-next-tailwind.git
synced 2025-12-05 18:46:45 -05:00
first commit
This commit is contained in:
1625
package-lock.json
generated
1625
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -9,19 +9,20 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.4"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
src/app/api/products/route.ts
Normal file
7
src/app/api/products/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch('http://localhost:8080/api/products'); // <-- your Spring backend endpoint
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
574
src/app/build/page.tsx
Normal file
574
src/app/build/page.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
// AR-15 Build Requirements grouped by main categories
|
||||
const buildGroups = [
|
||||
{
|
||||
name: 'Upper Parts',
|
||||
description: 'Components that make up the upper receiver assembly',
|
||||
components: [
|
||||
{
|
||||
id: 'upper-receiver',
|
||||
name: 'Upper Receiver',
|
||||
category: 'Upper',
|
||||
description: 'The upper receiver houses the barrel, bolt carrier group, and charging handle',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 150,
|
||||
notes: 'Can be purchased as complete upper or stripped'
|
||||
},
|
||||
{
|
||||
id: 'barrel',
|
||||
name: 'Barrel',
|
||||
category: 'Upper',
|
||||
description: 'The barrel determines accuracy and caliber compatibility',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 200,
|
||||
notes: 'Common lengths: 16", 18", 20"'
|
||||
},
|
||||
{
|
||||
id: 'bolt-carrier-group',
|
||||
name: 'Bolt Carrier Group (BCG)',
|
||||
category: 'Upper',
|
||||
description: 'Handles the firing, extraction, and ejection of rounds',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 150,
|
||||
notes: 'Mil-spec or enhanced options available'
|
||||
},
|
||||
{
|
||||
id: 'charging-handle',
|
||||
name: 'Charging Handle',
|
||||
category: 'Upper',
|
||||
description: 'Allows manual operation of the bolt carrier group',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 50,
|
||||
notes: 'Standard or ambidextrous options'
|
||||
},
|
||||
{
|
||||
id: 'gas-block',
|
||||
name: 'Gas Block',
|
||||
category: 'Upper',
|
||||
description: 'Controls gas flow from barrel to BCG',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 30,
|
||||
notes: 'Low-profile for free-float handguards'
|
||||
},
|
||||
{
|
||||
id: 'gas-tube',
|
||||
name: 'Gas Tube',
|
||||
category: 'Upper',
|
||||
description: 'Transfers gas from barrel to BCG',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 15,
|
||||
notes: 'Carbine, mid-length, or rifle length'
|
||||
},
|
||||
{
|
||||
id: 'handguard',
|
||||
name: 'Handguard',
|
||||
category: 'Upper',
|
||||
description: 'Provides grip and mounting points for accessories',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 100,
|
||||
notes: 'Free-float or drop-in options'
|
||||
},
|
||||
{
|
||||
id: 'muzzle-device',
|
||||
name: 'Muzzle Device',
|
||||
category: 'Upper',
|
||||
description: 'Flash hider, compensator, or suppressor mount',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 80,
|
||||
notes: 'A2 flash hider is standard'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Lower Parts',
|
||||
description: 'Components that make up the lower receiver assembly',
|
||||
components: [
|
||||
{
|
||||
id: 'lower-receiver',
|
||||
name: 'Lower Receiver',
|
||||
category: 'Lower',
|
||||
description: 'The lower receiver contains the trigger group and magazine well',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 100,
|
||||
notes: 'Must be purchased through FFL dealer'
|
||||
},
|
||||
{
|
||||
id: 'trigger',
|
||||
name: 'Trigger',
|
||||
category: 'Lower',
|
||||
description: 'Controls firing mechanism',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 60,
|
||||
notes: 'Mil-spec or enhanced triggers available'
|
||||
},
|
||||
{
|
||||
id: 'trigger-guard',
|
||||
name: 'Trigger Guard',
|
||||
category: 'Lower',
|
||||
description: 'Protects trigger from accidental discharge',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 10,
|
||||
notes: 'Often included with lower receiver'
|
||||
},
|
||||
{
|
||||
id: 'pistol-grip',
|
||||
name: 'Pistol Grip',
|
||||
category: 'Lower',
|
||||
description: 'Provides grip for firing hand',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 25,
|
||||
notes: 'Various ergonomic options available'
|
||||
},
|
||||
{
|
||||
id: 'buffer-tube',
|
||||
name: 'Buffer Tube',
|
||||
category: 'Lower',
|
||||
description: 'Houses buffer and spring for recoil management',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 40,
|
||||
notes: 'Carbine, A5, or rifle length'
|
||||
},
|
||||
{
|
||||
id: 'buffer',
|
||||
name: 'Buffer',
|
||||
category: 'Lower',
|
||||
description: 'Absorbs recoil energy',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 20,
|
||||
notes: 'H1, H2, H3 weights available'
|
||||
},
|
||||
{
|
||||
id: 'buffer-spring',
|
||||
name: 'Buffer Spring',
|
||||
category: 'Lower',
|
||||
description: 'Returns BCG to battery position',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 15,
|
||||
notes: 'Standard or enhanced springs'
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
name: 'Stock',
|
||||
category: 'Lower',
|
||||
description: 'Provides shoulder support and cheek weld',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 60,
|
||||
notes: 'Fixed or adjustable options'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Accessories',
|
||||
description: 'Additional components needed for a complete build',
|
||||
components: [
|
||||
{
|
||||
id: 'magazine',
|
||||
name: 'Magazine',
|
||||
category: 'Accessory',
|
||||
description: 'Holds and feeds ammunition',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 15,
|
||||
notes: '30-round capacity is standard'
|
||||
},
|
||||
{
|
||||
id: 'sights',
|
||||
name: 'Sights',
|
||||
category: 'Accessory',
|
||||
description: 'Iron sights or optic for aiming',
|
||||
required: true,
|
||||
status: 'pending',
|
||||
estimatedPrice: 100,
|
||||
notes: 'Backup iron sights recommended'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Flatten all components for filtering and sorting
|
||||
const allComponents = buildGroups.flatMap(group => group.components);
|
||||
|
||||
const categories = ["All", "Upper", "Lower", "Accessory"];
|
||||
|
||||
type SortField = 'name' | 'category' | 'estimatedPrice' | 'status';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function BuildPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter components
|
||||
const filteredComponents = allComponents.filter(component => {
|
||||
if (selectedCategory !== 'All' && component.category !== selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchTerm && !component.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!component.description.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort components
|
||||
const sortedComponents = [...filteredComponents].sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
|
||||
if (sortField === 'estimatedPrice') {
|
||||
aValue = a.estimatedPrice;
|
||||
bValue = b.estimatedPrice;
|
||||
} else if (sortField === 'category') {
|
||||
aValue = a.category.toLowerCase();
|
||||
bValue = b.category.toLowerCase();
|
||||
} else if (sortField === 'status') {
|
||||
aValue = a.status.toLowerCase();
|
||||
bValue = b.status.toLowerCase();
|
||||
} else {
|
||||
aValue = a.name.toLowerCase();
|
||||
bValue = b.name.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) {
|
||||
return '↕️';
|
||||
}
|
||||
return sortDirection === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'in-progress':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
|
||||
const completedCount = sortedComponents.filter(component => component.status === 'completed').length;
|
||||
|
||||
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Page Title */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">AR-15 Build Checklist</h1>
|
||||
<p className="text-gray-600 mt-2">Track your build progress and find required components</p>
|
||||
</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">{allComponents.length}</div>
|
||||
<div className="text-sm text-gray-500">Total Components</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completedCount}</div>
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
|
||||
<div className="text-sm text-gray-500">Remaining</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">${totalEstimatedCost}</div>
|
||||
<div className="text-sm text-gray-500">Estimated Cost</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">
|
||||
{/* Search Row */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search Components</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search components..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Category Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<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">
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort by */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
||||
<select
|
||||
value={sortField}
|
||||
onChange={(e) => handleSort(e.target.value as SortField)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="category">Category</option>
|
||||
<option value="estimatedPrice">Price</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Components Table */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Component</span>
|
||||
<span className="text-sm">{getSortIcon('name')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Category</span>
|
||||
<span className="text-sm">{getSortIcon('category')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('estimatedPrice')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Est. Price</span>
|
||||
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedComponents.length > 0 ? (
|
||||
buildGroups.map((group) => {
|
||||
// Filter components in this group that match current filters
|
||||
const groupComponents = group.components.filter(component =>
|
||||
sortedComponents.some(sorted => sorted.id === component.id)
|
||||
);
|
||||
|
||||
if (groupComponents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={group.name}>
|
||||
{/* Group Header */}
|
||||
<tr className="bg-gray-50">
|
||||
<td colSpan={7} className="px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-blue-600 font-semibold text-sm">
|
||||
{group.name === 'Upper Parts' ? '🔫' :
|
||||
group.name === 'Lower Parts' ? '🔧' : '📦'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{group.name}</h3>
|
||||
<p className="text-sm text-gray-500">{group.description}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{groupComponents.length} components
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Group Components */}
|
||||
{groupComponents.map((component) => (
|
||||
<tr key={component.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
|
||||
{component.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{component.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{component.required ? 'Required' : 'Optional'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{component.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-500 max-w-xs">
|
||||
{component.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
${component.estimatedPrice}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-500 max-w-xs">
|
||||
{component.notes}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/?category=${encodeURIComponent(component.category)}`}
|
||||
className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Find Parts
|
||||
</Link>
|
||||
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">No components found</div>
|
||||
<div className="text-sm">Try adjusting your filters or search terms</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="bg-gray-50 px-6 py-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {sortedComponents.length} of {allComponents.length} components
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
430
src/app/builds/page.tsx
Normal file
430
src/app/builds/page.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
// Sample build data
|
||||
const sampleBuilds = [
|
||||
{
|
||||
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 BuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function BuildsPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('completedDate');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [selectedStatus, setSelectedStatus] = useState<BuildStatus | 'all'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter builds
|
||||
const filteredBuilds = sampleBuilds.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 sortedBuilds = [...filteredBuilds].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: BuildStatus) => {
|
||||
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: BuildStatus) => {
|
||||
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 sampleBuilds[0]) => {
|
||||
return Math.round((build.components.completed / build.components.total) * 100);
|
||||
};
|
||||
|
||||
const totalBuilds = sampleBuilds.length;
|
||||
const completedBuilds = sampleBuilds.filter(build => build.status === 'completed').length;
|
||||
const inProgressBuilds = sampleBuilds.filter(build => build.status === 'in-progress').length;
|
||||
const totalValue = sampleBuilds.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">{totalBuilds}</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">{completedBuilds}</div>
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressBuilds}</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">
|
||||
{/* Search Row */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search Builds</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search builds..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 BuildStatus | '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">
|
||||
{sortedBuilds.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedBuilds.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>
|
||||
);
|
||||
}
|
||||
26
src/app/components/productCard.tsx
Normal file
26
src/app/components/productCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
brand: { name: string };
|
||||
description?: string;
|
||||
price?: number;
|
||||
vendor?: string;
|
||||
};
|
||||
|
||||
export function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<div className="border rounded-xl p-4 shadow hover:shadow-md transition">
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-40 object-contain mb-4"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold">{product.name}</h3>
|
||||
<p className="text-sm text-gray-500">{product.brand?.name}</p>
|
||||
{product.price && (
|
||||
<p className="text-md font-bold mt-2">${product.price.toFixed(2)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,20 +14,19 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Pew Builder - Firearm Parts Catalog",
|
||||
description: "Build your dream AR-15 with our comprehensive parts catalog and build checklist",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<Navbar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
971
src/app/page.tsx
971
src/app/page.tsx
@@ -1,103 +1,880 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
// Sample firearm parts data
|
||||
const parts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Faxon 16" Gunner Barrel - 5.56 NATO',
|
||||
description: 'Lightweight, high-performance AR-15 barrel with 1:8 twist rate',
|
||||
image_url: 'https://picsum.photos/300/200?random=1',
|
||||
brand: {
|
||||
id: 'b1',
|
||||
name: 'Faxon Firearms',
|
||||
},
|
||||
category: {
|
||||
id: 'c1',
|
||||
name: 'Barrel',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 189.99,
|
||||
url: 'https://primaryarms.com/faxon-16-gunner-barrel',
|
||||
vendor: {
|
||||
name: 'Primary Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'BCM M4 Upper Receiver',
|
||||
description: 'Forged upper receiver with M4 feed ramps and T-markings',
|
||||
image_url: 'https://picsum.photos/300/200?random=2',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c2',
|
||||
name: 'Upper Receiver',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 129.99,
|
||||
url: 'https://rainierarms.com/bcm-m4-upper',
|
||||
vendor: {
|
||||
name: 'Rainier Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Aero Precision Lower Receiver',
|
||||
description: 'Mil-spec forged lower receiver with trigger guard and threaded bolt catch',
|
||||
image_url: 'https://picsum.photos/300/200?random=3',
|
||||
brand: {
|
||||
id: 'b3',
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
category: {
|
||||
id: 'c3',
|
||||
name: 'Lower Receiver',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 89.99,
|
||||
url: 'https://aeroprecisionusa.com/lower',
|
||||
vendor: {
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Toolcraft BCG - Nitride',
|
||||
description: 'Mil-spec bolt carrier group with Carpenter 158 bolt and nitride finish',
|
||||
image_url: 'https://picsum.photos/300/200?random=4',
|
||||
brand: {
|
||||
id: 'b4',
|
||||
name: 'Toolcraft',
|
||||
},
|
||||
category: {
|
||||
id: 'c4',
|
||||
name: 'BCG',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 149.99,
|
||||
url: 'https://wctoolcraft.com/bcg-nitride',
|
||||
vendor: {
|
||||
name: 'WC Toolcraft',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Geissele SSA-E Trigger',
|
||||
description: 'Two-stage trigger with 3.5lb total pull weight and enhanced reliability',
|
||||
image_url: 'https://picsum.photos/300/200?random=5',
|
||||
brand: {
|
||||
id: 'b5',
|
||||
name: 'Geissele',
|
||||
},
|
||||
category: {
|
||||
id: 'c5',
|
||||
name: 'Trigger',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 249.99,
|
||||
url: 'https://geissele.com/ssa-e',
|
||||
vendor: {
|
||||
name: 'Geissele',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Magpul CTR Stock',
|
||||
description: 'Collapsible stock with friction lock system and QD sling mount',
|
||||
image_url: 'https://picsum.photos/300/200?random=6',
|
||||
brand: {
|
||||
id: 'b6',
|
||||
name: 'Magpul',
|
||||
},
|
||||
category: {
|
||||
id: 'c6',
|
||||
name: 'Stock',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 69.99,
|
||||
url: 'https://magpul.com/ctr-stock',
|
||||
vendor: {
|
||||
name: 'Magpul',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Radian Raptor Charging Handle',
|
||||
description: 'Ambidextrous charging handle with oversized latches and smooth operation',
|
||||
image_url: 'https://picsum.photos/300/200?random=7',
|
||||
brand: {
|
||||
id: 'b7',
|
||||
name: 'Radian Weapons',
|
||||
},
|
||||
category: {
|
||||
id: 'c7',
|
||||
name: 'Charging Handle',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 89.99,
|
||||
url: 'https://radianweapons.com/raptor',
|
||||
vendor: {
|
||||
name: 'Radian Weapons',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'BCM Gunfighter Handguard - 15" M-LOK',
|
||||
description: 'Free-float handguard with M-LOK slots and integrated QD mounts',
|
||||
image_url: 'https://picsum.photos/300/200?random=8',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c8',
|
||||
name: 'Handguard',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 199.99,
|
||||
url: 'https://bravocompanyusa.com/handguard',
|
||||
vendor: {
|
||||
name: 'BCM',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'SureFire WarComp Flash Hider',
|
||||
description: 'Compensator/flash hider hybrid with suppressor mount capability',
|
||||
image_url: 'https://picsum.photos/300/200?random=9',
|
||||
brand: {
|
||||
id: 'b8',
|
||||
name: 'SureFire',
|
||||
},
|
||||
category: {
|
||||
id: 'c9',
|
||||
name: 'Muzzle Device',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 159.99,
|
||||
url: 'https://surefire.com/warcomp',
|
||||
vendor: {
|
||||
name: 'SureFire',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'Aero Precision Gas Block - Low Profile',
|
||||
description: 'Low-profile adjustable gas block for free-float handguards',
|
||||
image_url: 'https://picsum.photos/300/200?random=10',
|
||||
brand: {
|
||||
id: 'b3',
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
category: {
|
||||
id: 'c10',
|
||||
name: 'Gas Block',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 49.99,
|
||||
url: 'https://aeroprecisionusa.com/gas-block',
|
||||
vendor: {
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'BCM Gas Tube - Mid Length',
|
||||
description: 'Stainless steel gas tube for mid-length gas systems',
|
||||
image_url: 'https://picsum.photos/300/200?random=11',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c11',
|
||||
name: 'Gas Tube',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 19.99,
|
||||
url: 'https://bravocompanyusa.com/gas-tube',
|
||||
vendor: {
|
||||
name: 'BCM',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Magpul MOE Pistol Grip',
|
||||
description: 'Ergonomic pistol grip with storage compartment and enhanced texture',
|
||||
image_url: 'https://picsum.photos/300/200?random=12',
|
||||
brand: {
|
||||
id: 'b6',
|
||||
name: 'Magpul',
|
||||
},
|
||||
category: {
|
||||
id: 'c12',
|
||||
name: 'Pistol Grip',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 24.99,
|
||||
url: 'https://magpul.com/moe-grip',
|
||||
vendor: {
|
||||
name: 'Magpul',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'BCM Buffer Tube - Mil-Spec',
|
||||
description: 'Mil-spec buffer tube with proper castle nut and end plate',
|
||||
image_url: 'https://picsum.photos/300/200?random=13',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c13',
|
||||
name: 'Buffer Tube',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 44.99,
|
||||
url: 'https://bravocompanyusa.com/buffer-tube',
|
||||
vendor: {
|
||||
name: 'BCM',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'H2 Buffer Weight',
|
||||
description: 'Heavy buffer weight for improved recoil management',
|
||||
image_url: 'https://picsum.photos/300/200?random=14',
|
||||
brand: {
|
||||
id: 'b9',
|
||||
name: 'Spikes Tactical',
|
||||
},
|
||||
category: {
|
||||
id: 'c14',
|
||||
name: 'Buffer',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 29.99,
|
||||
url: 'https://spikestactical.com/h2-buffer',
|
||||
vendor: {
|
||||
name: 'Spikes Tactical',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Sprinco Blue Buffer Spring',
|
||||
description: 'Enhanced buffer spring for improved reliability and reduced wear',
|
||||
image_url: 'https://picsum.photos/300/200?random=15',
|
||||
brand: {
|
||||
id: 'b10',
|
||||
name: 'Sprinco',
|
||||
},
|
||||
category: {
|
||||
id: 'c15',
|
||||
name: 'Buffer Spring',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 19.99,
|
||||
url: 'https://sprinco.com/blue-spring',
|
||||
vendor: {
|
||||
name: 'Sprinco',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Magpul PMAG 30-Round Magazine',
|
||||
description: '30-round polymer magazine with anti-tilt follower and dust cover',
|
||||
image_url: 'https://picsum.photos/300/200?random=16',
|
||||
brand: {
|
||||
id: 'b6',
|
||||
name: 'Magpul',
|
||||
},
|
||||
category: {
|
||||
id: 'c16',
|
||||
name: 'Magazine',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 14.99,
|
||||
url: 'https://magpul.com/pmag',
|
||||
vendor: {
|
||||
name: 'Magpul',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
name: 'Troy Industries BUIS - Folding',
|
||||
description: 'Folding backup iron sights with micro-adjustable elevation',
|
||||
image_url: 'https://picsum.photos/300/200?random=17',
|
||||
brand: {
|
||||
id: 'b11',
|
||||
name: 'Troy Industries',
|
||||
},
|
||||
category: {
|
||||
id: 'c17',
|
||||
name: 'Sights',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 129.99,
|
||||
url: 'https://troyind.com/buis',
|
||||
vendor: {
|
||||
name: 'Troy Industries',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
name: 'Aero Precision Trigger Guard',
|
||||
description: 'Enhanced trigger guard with oversized opening for gloved hands',
|
||||
image_url: 'https://picsum.photos/300/200?random=18',
|
||||
brand: {
|
||||
id: 'b3',
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
category: {
|
||||
id: 'c18',
|
||||
name: 'Trigger Guard',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 12.99,
|
||||
url: 'https://aeroprecisionusa.com/trigger-guard',
|
||||
vendor: {
|
||||
name: 'Aero Precision',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
name: 'Larue MBT-2S Trigger',
|
||||
description: 'Two-stage trigger with 4.5lb total pull weight and excellent value',
|
||||
image_url: 'https://picsum.photos/300/200?random=19',
|
||||
brand: {
|
||||
id: 'b12',
|
||||
name: 'LaRue Tactical',
|
||||
},
|
||||
category: {
|
||||
id: 'c5',
|
||||
name: 'Trigger',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 89.99,
|
||||
url: 'https://laruetactical.com/mbt-2s',
|
||||
vendor: {
|
||||
name: 'LaRue Tactical',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
name: 'Daniel Defense 18" Barrel - 5.56 NATO',
|
||||
description: 'Cold hammer forged barrel with 1:7 twist rate for precision shooting',
|
||||
image_url: 'https://picsum.photos/300/200?random=20',
|
||||
brand: {
|
||||
id: 'b13',
|
||||
name: 'Daniel Defense',
|
||||
},
|
||||
category: {
|
||||
id: 'c1',
|
||||
name: 'Barrel',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 399.99,
|
||||
url: 'https://danieldefense.com/18-barrel',
|
||||
vendor: {
|
||||
name: 'Daniel Defense',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
name: 'BCM Complete Lower Receiver',
|
||||
description: 'Complete lower receiver with BCM trigger, grip, and stock',
|
||||
image_url: 'https://picsum.photos/300/200?random=21',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c3',
|
||||
name: 'Lower Receiver',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 449.99,
|
||||
url: 'https://bravocompanyusa.com/complete-lower',
|
||||
vendor: {
|
||||
name: 'BCM',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
name: 'Aimpoint PRO Red Dot Sight',
|
||||
description: '2 MOA red dot sight with 2x magnification and 40mm objective',
|
||||
image_url: 'https://picsum.photos/300/200?random=22',
|
||||
brand: {
|
||||
id: 'b14',
|
||||
name: 'Aimpoint',
|
||||
},
|
||||
category: {
|
||||
id: 'c17',
|
||||
name: 'Sights',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 449.99,
|
||||
url: 'https://aimpoint.com/pro',
|
||||
vendor: {
|
||||
name: 'Aimpoint',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
// Extract unique values for dropdowns
|
||||
const categories = ['All', ...Array.from(new Set(parts.map(part => part.category.name)))];
|
||||
const brands = ['All', ...Array.from(new Set(parts.map(part => part.brand.name)))];
|
||||
const vendors = ['All', ...Array.from(new Set(parts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
|
||||
|
||||
type SortField = 'name' | 'category' | 'price';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
const [selectedBrand, setSelectedBrand] = useState('All');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [minPrice, setMinPrice] = useState('');
|
||||
const [maxPrice, setMaxPrice] = useState('');
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
// Read category from URL parameter on page load
|
||||
useEffect(() => {
|
||||
const categoryParam = searchParams.get('category');
|
||||
if (categoryParam && categories.includes(categoryParam)) {
|
||||
setSelectedCategory(categoryParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Filter parts by all criteria
|
||||
const filteredParts = parts.filter(part => {
|
||||
// Category filter
|
||||
if (selectedCategory !== 'All' && part.category.name !== selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if (selectedBrand !== 'All' && part.brand.name !== selectedBrand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vendor filter
|
||||
if (selectedVendor !== 'All' && !part.offers.some(offer => offer.vendor.name === selectedVendor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchTerm && !part.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!part.description.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
const minPriceNum = minPrice ? parseFloat(minPrice) : 0;
|
||||
const maxPriceNum = maxPrice ? parseFloat(maxPrice) : Infinity;
|
||||
const partPrice = Math.min(...part.offers.map(offer => offer.price));
|
||||
|
||||
if (partPrice < minPriceNum || partPrice > maxPriceNum) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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('');
|
||||
setMinPrice('');
|
||||
setMaxPrice('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || minPrice || maxPrice;
|
||||
|
||||
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">
|
||||
Parts Catalog
|
||||
{selectedCategory !== 'All' && (
|
||||
<span className="text-blue-600 ml-2 text-2xl">
|
||||
- {selectedCategory}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{selectedCategory !== 'All'
|
||||
? `Showing ${selectedCategory} parts for your build`
|
||||
: 'Browse and filter firearm parts for your build'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</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">
|
||||
{/* Search Row */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search parts..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
{/* Category Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Brand Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Brand</label>
|
||||
<select
|
||||
value={selectedBrand}
|
||||
onChange={(e) => setSelectedBrand(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"
|
||||
>
|
||||
{brands.map((brand) => (
|
||||
<option key={brand} value={brand}>
|
||||
{brand}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Vendor Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor</label>
|
||||
<select
|
||||
value={selectedVendor}
|
||||
onChange={(e) => setSelectedVendor(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"
|
||||
>
|
||||
{vendors.map((vendor) => (
|
||||
<option key={vendor} value={vendor}>
|
||||
{vendor}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Min Price */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Min Price</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Price */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Price</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
className={`w-full px-4 py-2 rounded-lg transition-colors ${
|
||||
hasActiveFilters
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parts Table */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Name</span>
|
||||
<span className="text-sm">{getSortIcon('name')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Brand
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('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-gray-500 uppercase tracking-wider">
|
||||
Vendor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedParts.length > 0 ? (
|
||||
sortedParts.map((part) => (
|
||||
<tr key={part.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{part.category.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{part.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-500 max-w-xs truncate">
|
||||
{part.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
${Math.min(...part.offers.map(offer => offer.price))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{part.offers[0]?.vendor.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors">
|
||||
Add to Build
|
||||
</button>
|
||||
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
|
||||
⭐
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">No parts found</div>
|
||||
<div className="text-sm">Try adjusting your filters or search terms</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="bg-gray-50 px-6 py-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {sortedParts.length} of {parts.length} parts
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="text-sm text-gray-500">
|
||||
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
src/app/products/page.tsx
Normal file
27
src/app/products/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { mockProducts } from '@/mock/products';
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">All Products</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{mockProducts.map((product) => (
|
||||
<div key={product.id} className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{product.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{product.description}</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${product.offers[0]?.price}
|
||||
</span>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Add to Build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/Navbar.tsx
Normal file
82
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Parts Catalog', href: '/' },
|
||||
{ name: 'Build Checklist', href: '/build' },
|
||||
{ name: 'My Builds', href: '/builds' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">
|
||||
Pew Builder
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button className="text-gray-700 hover:text-gray-900 focus:outline-none focus:text-gray-900">
|
||||
<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.name}
|
||||
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.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
48
src/mock/product.ts
Normal file
48
src/mock/product.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Faxon 16" Gunner Barrel - 5.56 NATO',
|
||||
description: 'Lightweight, high-performance AR-15 barrel.',
|
||||
image_url: 'https://via.placeholder.com/300x200?text=Barrel',
|
||||
brand: {
|
||||
id: 'b1',
|
||||
name: 'Faxon Firearms',
|
||||
},
|
||||
category: {
|
||||
id: 'c1',
|
||||
name: 'Barrel',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 189.99,
|
||||
url: 'https://primaryarms.com/faxon-16-gunner-barrel',
|
||||
vendor: {
|
||||
name: 'Primary Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'BCM M4 Upper Receiver',
|
||||
description: 'Forged upper with M4 feed ramps.',
|
||||
image_url: 'https://via.placeholder.com/300x200?text=Upper',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c2',
|
||||
name: 'Upper Receiver',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 129.99,
|
||||
url: 'https://rainierarms.com/bcm-m4-upper',
|
||||
vendor: {
|
||||
name: 'Rainier Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
48
src/mock/products.ts
Normal file
48
src/mock/products.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Faxon 16" Gunner Barrel - 5.56 NATO',
|
||||
description: 'Lightweight, high-performance AR-15 barrel.',
|
||||
image_url: 'https://via.placeholder.com/300x200?text=Barrel',
|
||||
brand: {
|
||||
id: 'b1',
|
||||
name: 'Faxon Firearms',
|
||||
},
|
||||
category: {
|
||||
id: 'c1',
|
||||
name: 'Barrel',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 189.99,
|
||||
url: 'https://primaryarms.com/faxon-16-gunner-barrel',
|
||||
vendor: {
|
||||
name: 'Primary Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'BCM M4 Upper Receiver',
|
||||
description: 'Forged upper with M4 feed ramps.',
|
||||
image_url: 'https://via.placeholder.com/300x200?text=Upper',
|
||||
brand: {
|
||||
id: 'b2',
|
||||
name: 'BCM',
|
||||
},
|
||||
category: {
|
||||
id: 'c2',
|
||||
name: 'Upper Receiver',
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
price: 129.99,
|
||||
url: 'https://rainierarms.com/bcm-m4-upper',
|
||||
vendor: {
|
||||
name: 'Rainier Arms',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
12
tailwind.config.js
Normal file
12
tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user