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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"next": "15.3.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"next": "15.3.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"autoprefixer": "^10.4.21",
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.4",
|
"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";
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
:root {
|
@tailwind utilities;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,20 +14,19 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Pew Builder - Firearm Parts Catalog",
|
||||||
description: "Generated by create next app",
|
description: "Build your dream AR-15 with our comprehensive parts catalog and build checklist",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Navbar />
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
const searchParams = useSearchParams();
|
||||||
<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)]">
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
const [selectedBrand, setSelectedBrand] = useState('All');
|
||||||
<Image
|
const [selectedVendor, setSelectedVendor] = useState('All');
|
||||||
className="dark:invert"
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
src="/next.svg"
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
alt="Next.js logo"
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
width={180}
|
const [minPrice, setMinPrice] = useState('');
|
||||||
height={38}
|
const [maxPrice, setMaxPrice] = useState('');
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
// Read category from URL parameter on page load
|
||||||
<a
|
useEffect(() => {
|
||||||
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"
|
const categoryParam = searchParams.get('category');
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
if (categoryParam && categories.includes(categoryParam)) {
|
||||||
target="_blank"
|
setSelectedCategory(categoryParam);
|
||||||
rel="noopener noreferrer"
|
}
|
||||||
>
|
}, [searchParams]);
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
// Filter parts by all criteria
|
||||||
src="/vercel.svg"
|
const filteredParts = parts.filter(part => {
|
||||||
alt="Vercel logomark"
|
// Category filter
|
||||||
width={20}
|
if (selectedCategory !== 'All' && part.category.name !== selectedCategory) {
|
||||||
height={20}
|
return false;
|
||||||
/>
|
}
|
||||||
Deploy now
|
|
||||||
</a>
|
// Brand filter
|
||||||
<a
|
if (selectedBrand !== 'All' && part.brand.name !== selectedBrand) {
|
||||||
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]"
|
return false;
|
||||||
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"
|
// Vendor filter
|
||||||
>
|
if (selectedVendor !== 'All' && !part.offers.some(offer => offer.vendor.name === selectedVendor)) {
|
||||||
Read our docs
|
return false;
|
||||||
</a>
|
}
|
||||||
|
|
||||||
|
// 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>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
{/* Search and Filters */}
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<div className="bg-white border-b">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
target="_blank"
|
{/* Search Row */}
|
||||||
rel="noopener noreferrer"
|
<div className="mb-4 flex justify-end">
|
||||||
>
|
<div className="w-1/2">
|
||||||
<Image
|
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||||
aria-hidden
|
<input
|
||||||
src="/file.svg"
|
type="text"
|
||||||
alt="File icon"
|
placeholder="Search parts..."
|
||||||
width={16}
|
value={searchTerm}
|
||||||
height={16}
|
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"
|
||||||
Learn
|
/>
|
||||||
</a>
|
</div>
|
||||||
<a
|
</div>
|
||||||
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"
|
{/* Filters Row */}
|
||||||
target="_blank"
|
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||||
rel="noopener noreferrer"
|
{/* Category Dropdown */}
|
||||||
>
|
<div>
|
||||||
<Image
|
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||||
aria-hidden
|
<select
|
||||||
src="/window.svg"
|
value={selectedCategory}
|
||||||
alt="Window icon"
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
width={16}
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
height={16}
|
>
|
||||||
/>
|
{categories.map((category) => (
|
||||||
Examples
|
<option key={category} value={category}>
|
||||||
</a>
|
{category}
|
||||||
<a
|
</option>
|
||||||
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"
|
</select>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
{/* Brand Dropdown */}
|
||||||
<Image
|
<div>
|
||||||
aria-hidden
|
<label className="block text-sm font-medium text-gray-700 mb-1">Brand</label>
|
||||||
src="/globe.svg"
|
<select
|
||||||
alt="Globe icon"
|
value={selectedBrand}
|
||||||
width={16}
|
onChange={(e) => setSelectedBrand(e.target.value)}
|
||||||
height={16}
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
>
|
||||||
Go to nextjs.org →
|
{brands.map((brand) => (
|
||||||
</a>
|
<option key={brand} value={brand}>
|
||||||
</footer>
|
{brand}
|
||||||
</div>
|
</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