first commit

This commit is contained in:
2025-06-29 07:12:20 -04:00
parent 6612f40d9b
commit cfcc4c480e
16 changed files with 3156 additions and 767 deletions

1625
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View 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
View 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
View 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>
);
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}
// 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>
</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"
/> />
Deploy now </div>
</a> </div>
<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]" {/* Filters Row */}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <div className="grid grid-cols-1 md:grid-cols-6 gap-4">
target="_blank" {/* Category Dropdown */}
rel="noopener noreferrer" <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"
> >
Read our docs {categories.map((category) => (
</a> <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> </div>
</main> </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>
); );
} }

27
src/app/products/page.tsx Normal file
View 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
View 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
View 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
View 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
View 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: [],
};