/admin and with working with db. data is pulling from db

This commit is contained in:
2025-06-30 20:26:00 -04:00
parent 5c046874a8
commit b478d9797d
33 changed files with 1214 additions and 58 deletions

View File

@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitted(true);
}
return (
<div className="flex flex-1 items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/>
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={submitted}
/>
<button
type="submit"
className="w-full btn btn-primary"
disabled={submitted}
>
{submitted ? 'Check your email' : 'Send reset link'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Back to login</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import Link from 'next/link';
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
{/* Simple navbar with back button */}
<nav className="bg-white dark:bg-zinc-900 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-start h-16">
<div className="flex items-center">
<Link
href="/"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100 focus:outline-none transition"
>
<svg
className="h-5 w-5 mr-1"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
</Link>
</div>
</div>
</div>
</nav>
{/* Main content */}
<main className="flex-1">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: searchParams.get('callbackUrl') || '/',
});
setLoading(false);
if (res?.error) {
setError('Invalid email or password');
} else if (res?.ok) {
router.push(res.url || '/');
}
}
async function handleGoogle() {
setLoading(true);
await signIn('google', { callbackUrl: searchParams.get('callbackUrl') || '/' });
setLoading(false);
}
return (
<div className="min-h-screen flex">
{/* Left side image or illustration */}
<div className="hidden lg:block relative w-0 flex-1 bg-gray-900">
{/* You can replace this with your own image or illustration */}
<img
className="absolute inset-0 h-full w-full object-cover opacity-80"
src="/window.svg"
alt="Login visual"
/>
</div>
{/* Right side form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-zinc-900 min-h-screen">
<div className="mx-auto w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Or{' '}
<Link href="/account/register" className="font-medium text-primary-600 hover:text-primary-500">
Sign Up For Free
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="remember" value="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Email address"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
disabled={loading}
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-2">{error}</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="checkbox checkbox-primary"
disabled={loading}
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/account/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
{/* Social login buttons */}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-zinc-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400">
Or continue with
</span>
</div>
</div>
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={handleGoogle}
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-zinc-700 rounded-md shadow-sm bg-white dark:bg-zinc-800 text-sm font-medium text-gray-500 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition"
disabled={loading}
>
<span className="sr-only">Sign in with Google</span>
{/* Google Icon Placeholder */}
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M21.35 11.1H12v2.8h5.35c-.23 1.2-1.4 3.5-5.35 3.5-3.22 0-5.85-2.67-5.85-5.9s2.63-5.9 5.85-5.9c1.83 0 3.06.78 3.76 1.44l2.57-2.5C17.09 3.59 14.77 2.5 12 2.5 6.75 2.5 2.5 6.75 2.5 12s4.25 9.5 9.5 9.5c5.47 0 9.09-3.84 9.09-9.25 0-.62-.07-1.08-.16-1.55z" /></svg>
<span className="ml-2">Sign in with Google</span>
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'unauthenticated') {
router.replace('/account/login');
}
}, [status, router]);
if (status === 'loading') {
return <div className="flex justify-center items-center h-64">Loading...</div>;
}
if (!session?.user) {
return null;
}
return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-zinc-900 rounded shadow">
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<div className="space-y-2">
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>
<div><span className="font-semibold">Email:</span> {session.user.email}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from 'next-auth/react';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setLoading(false);
setError(data.error || 'Registration failed');
return;
}
// Auto-login after registration
const signInRes = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: '/account/profile',
});
setLoading(false);
if (signInRes?.ok) {
router.push('/');
} else {
router.push('/account/login?registered=1');
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-zinc-900">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={loading}
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="absolute inset-y-0 right-2 flex items-center text-xs text-gray-500 dark:text-gray-300"
tabIndex={-1}
onClick={() => setShowPassword(v => !v)}
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{error && <div className="text-red-600 text-sm">{error}</div>}
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Already have an account? Sign in</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,774 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import React from 'react';
import SearchInput from '@/components/SearchInput';
import RestrictionAlert from '@/components/RestrictionAlert';
import { useBuildStore } from '@/store/useBuildStore';
import { mockProducts } from '@/mock/product';
import { Dialog } from '@headlessui/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';
// Map checklist component categories to product categories for filtering
const getProductCategory = (componentCategory: string): string => {
const categoryMap: Record<string, string> = {
'Upper': 'Upper Receiver', // Default to Upper Receiver for Upper category
'Lower': 'Lower Receiver', // Default to Lower Receiver for Lower category
'Accessory': 'Magazine', // Default to Magazine for Accessory category
};
return categoryMap[componentCategory] || 'Magazine';
};
// Map specific checklist components to product categories
const getProductCategoryForComponent = (componentName: string): string => {
const componentMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper Receiver',
'Barrel': 'Barrel',
'Bolt Carrier Group (BCG)': 'BCG',
'Charging Handle': 'Charging Handle',
'Gas Block': 'Gas Block',
'Gas Tube': 'Gas Tube',
'Handguard': 'Handguard',
'Muzzle Device': 'Muzzle Device',
// Lower components
'Lower Receiver': 'Lower Receiver',
'Trigger': 'Trigger',
'Trigger Guard': 'Lower Receiver',
'Pistol Grip': 'Lower Receiver',
'Buffer Tube': 'Lower Receiver',
'Buffer': 'Lower Receiver',
'Buffer Spring': 'Lower Receiver',
'Stock': 'Stock',
// Accessories
'Magazine': 'Magazine',
'Sights': 'Magazine',
};
return componentMap[componentName] || 'Lower Receiver';
};
export { buildGroups };
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('');
const selectedParts = useBuildStore((state) => state.selectedParts);
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
const clearBuild = useBuildStore((state) => state.clearBuild);
const [showClearModal, setShowClearModal] = useState(false);
// 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 'in-progress': return 'bg-yellow-100 text-yellow-800';
case 'pending': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
const completedCount = sortedComponents.filter(component => selectedParts[component.id]).length;
const actualTotalCost = sortedComponents.reduce((sum, component) => {
const selected = selectedParts[component.id];
if (selected && selected.offers) {
return sum + Math.min(...selected.offers.map(offer => offer.price));
}
return sum;
}, 0);
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
// Check for restricted parts in the build
const getRestrictedParts = () => {
const restrictedParts: Array<{ part: any; restriction: string }> = [];
Object.values(selectedParts).forEach(selectedPart => {
if (selectedPart) {
const product = mockProducts.find(p => p.id === selectedPart.id);
if (product?.restrictions) {
const restrictions = product.restrictions;
if (restrictions.nfa) restrictedParts.push({ part: product, restriction: 'NFA' });
if (restrictions.sbr) restrictedParts.push({ part: product, restriction: 'SBR' });
if (restrictions.suppressor) restrictedParts.push({ part: product, restriction: 'Suppressor' });
if (restrictions.stateRestrictions && restrictions.stateRestrictions.length > 0) {
restrictedParts.push({ part: product, restriction: 'State Restrictions' });
}
}
}
});
return restrictedParts;
};
const restrictedParts = getRestrictedParts();
const hasNFAItems = restrictedParts.some(rp => rp.restriction === 'NFA');
const hasSuppressors = restrictedParts.some(rp => rp.restriction === 'Suppressor');
const hasStateRestrictions = restrictedParts.some(rp => rp.restriction === 'State Restrictions');
const [showRestrictionAlerts, setShowRestrictionAlerts] = useState(true);
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">Plan Your Build</h1>
</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 items-center">
<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 flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
<div>
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Cost</div>
</div>
<button
className="btn btn-outline btn-error ml-0 md:ml-4"
onClick={() => setShowClearModal(true)}
>
Clear Build
</button>
</div>
</div>
</div>
</div>
{/* Clear Build Modal */}
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
<Dialog.Description className="mb-4 text-gray-600">
Are you sure you want to clear your entire build? This action cannot be undone.
</Dialog.Description>
<div className="flex justify-end gap-2">
<button
className="btn btn-sm btn-ghost"
onClick={() => setShowClearModal(false)}
>
Cancel
</button>
<button
className="btn btn-sm btn-error"
onClick={() => {
clearBuild();
setShowClearModal(false);
}}
>
Yes, Clear Build
</button>
</div>
</div>
</div>
</Dialog>
{/* Restriction Alerts */}
{restrictedParts.length > 0 && (
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<span className="text-sm font-medium text-gray-700">
{restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected
</span>
</div>
<button
onClick={() => setShowRestrictionAlerts(!showRestrictionAlerts)}
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
{showRestrictionAlerts ? 'Hide' : 'Show'} details
<svg
className={`w-4 h-4 transition-transform ${showRestrictionAlerts ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{showRestrictionAlerts && (
<div className="space-y-2">
{hasNFAItems && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🔒</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">NFA Items in Your Build</div>
<div className="text-xs text-yellow-700 mt-1">
Your build contains items that require National Firearms Act registration.
</div>
</div>
</div>
</div>
)}
{hasSuppressors && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🔇</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">Suppressor in Your Build</div>
<div className="text-xs text-yellow-700 mt-1">
Sound suppressor requires NFA registration. Processing times: 6-12 months.
</div>
</div>
</div>
</div>
)}
{hasStateRestrictions && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🗺</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">State Restrictions Apply</div>
<div className="text-xs text-yellow-700 mt-1">
Some items may be restricted in certain states. Verify local laws.
</div>
</div>
</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-3">
{/* Filters Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Category Dropdown */}
<div className="col-span-1">
<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-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* Status Filter */}
<div className="col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm">
<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 className="col-span-1">
<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-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
>
<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="col-span-1 flex items-end">
<button className="w-full px-3 py-1.5 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm">
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 border border-gray-200">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
<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 cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('estimatedPrice')}
>
<div className="flex items-center space-x-1">
<span>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">
Selected Product
</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-100">
<td colSpan={7} className="px-6 py-2">
<div className="flex items-center">
<div>
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
</div>
<div className="ml-auto text-right">
<div className="text-xs text-gray-500 font-medium">
{groupComponents.length} components
</div>
</div>
</div>
</td>
</tr>
{/* Group Components */}
{groupComponents.map((component) => {
const selected = selectedParts[component.id];
return (
<tr key={component.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
{selected ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Selected
</span>
) : (
<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">
{selected ? (
<div>
<div className="text-sm font-medium text-gray-900">
<Link
href={`/products/${selected.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{selected.name}
</Link>
</div>
<div className="text-xs text-gray-500">
{selected.brand.name} &middot; {component.required ? 'Required' : 'Optional'}
</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-xs text-gray-500">
{component.required ? 'Required' : 'Optional'}
</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getProductCategoryForComponent(component.name)}
</span>
</td>
<td className="px-6 py-4">
{selected ? (
<div className="text-sm font-semibold text-gray-900">
${Math.min(...selected.offers?.map(offer => offer.price) || [0]).toFixed(2)}
</div>
) : (
<div className="text-sm text-gray-400">
</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">
{selected ? (
<button
className="btn btn-outline btn-sm"
onClick={() => removePartForComponent(component.id)}
>
Remove
</button>
) : (
<Link
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
className="btn btn-primary btn-sm"
>
Find Parts
</Link>
)}
</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: ${actualTotalCost.toFixed(2)}
</div>
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,429 @@
'use client';
import { useState } from 'react';
import SearchInput from '@/components/SearchInput';
// Sample build data
const sampleMyBuilds = [
{
id: '1',
name: 'Budget AR-15 Build',
description: 'A cost-effective AR-15 build using quality budget components',
status: 'completed' as const,
totalCost: 847.50,
completedDate: '2024-01-15',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Budget', '5.56 NATO', '16" Barrel'],
image: 'https://picsum.photos/400/250?random=1'
},
{
id: '2',
name: 'Precision Long Range',
description: 'High-end precision build optimized for long-range accuracy',
status: 'in-progress' as const,
totalCost: 2847.99,
startedDate: '2024-02-01',
components: {
total: 18,
completed: 12,
categories: {
'Upper': 6,
'Lower': 4,
'Accessory': 2
}
},
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=2'
},
{
id: '3',
name: 'Home Defense Setup',
description: 'Compact AR-15 configured for home defense scenarios',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-03-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
image: 'https://picsum.photos/400/250?random=3'
},
{
id: '4',
name: 'Competition Rifle',
description: 'Lightweight competition build for 3-gun matches',
status: 'completed' as const,
totalCost: 1650.75,
completedDate: '2023-12-10',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Competition', '5.56 NATO', '18" Barrel'],
image: 'https://picsum.photos/400/250?random=4'
},
{
id: '5',
name: 'Suppressed SBR',
description: 'Short-barreled rifle build with suppressor integration',
status: 'in-progress' as const,
totalCost: 1895.25,
startedDate: '2024-01-20',
components: {
total: 18,
completed: 8,
categories: {
'Upper': 4,
'Lower': 3,
'Accessory': 1
}
},
tags: ['SBR', 'Suppressed', '300 BLK'],
image: 'https://picsum.photos/400/250?random=5'
},
{
id: '6',
name: 'Retro M16A1 Clone',
description: 'Faithful reproduction of the classic M16A1 rifle',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-04-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Retro', '5.56 NATO', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=6'
}
];
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc';
export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Filter builds
const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false;
}
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
});
// Sort builds
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'totalCost') {
aValue = a.totalCost;
bValue = b.totalCost;
} else if (sortField === 'status') {
aValue = a.status;
bValue = b.status;
} else if (sortField === 'completedDate') {
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const getStatusColor = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
case 'planning':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return '✓';
case 'in-progress':
return '🔄';
case 'planning':
return '📋';
default:
return '❓';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100);
};
const totalMyBuilds = sampleMyBuilds.length;
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
return (
<main className="min-h-screen bg-gray-50">
{/* Page Title */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">My Builds</h1>
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
</div>
</div>
{/* Build Summary */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Value</div>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Search Row */}
<div className="mb-4 flex justify-end">
<div className="w-1/2">
<SearchInput
label="Search My Builds"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search my builds..."
/>
</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 MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="in-progress">In Progress</option>
<option value="planning">Planning</option>
</select>
</div>
{/* Sort by */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="completedDate">Date</option>
<option value="name">Name</option>
<option value="totalCost">Cost</option>
<option value="status">Status</option>
</select>
</div>
{/* Sort Direction */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
<select
value={sortDirection}
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
</div>
{/* New Build Button */}
<div className="flex items-end">
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
+ New Build
</button>
</div>
</div>
</div>
</div>
{/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */}
<div className="h-48 bg-gray-200 relative">
<img
src={build.image}
alt={build.name}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
<div className="text-center text-gray-600">
<div className="text-4xl mb-2">🔫</div>
<div className="text-sm font-medium">{build.name}</div>
</div>
</div>
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
{getStatusIcon(build.status)} {build.status}
</span>
</div>
</div>
{/* Build Content */}
<div className="p-6">
{/* Build Title and Date */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
<div className="text-xs text-gray-500">
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress</span>
<span>{build.components.completed}/{build.components.total} components</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getProgressPercentage(build)}%` }}
></div>
</div>
</div>
{/* Component Categories */}
<div className="mb-4">
<div className="flex space-x-2">
{Object.entries(build.components.categories).map(([category, count]) => (
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
{category}: {count}
</span>
))}
</div>
</div>
{/* Tags */}
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{build.tags.map((tag) => (
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
{tag}
</span>
))}
</div>
</div>
{/* Cost and Actions */}
<div className="flex justify-between items-center">
<div className="text-lg font-bold text-gray-900">
${build.totalCost.toFixed(2)}
</div>
<div className="flex space-x-2">
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
View Details
</button>
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
Edit
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-lg font-medium mb-2">No builds found</div>
<div className="text-sm">Try adjusting your filters or create a new build</div>
</div>
</div>
)}
</div>
</main>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import Tooltip from '@/components/Tooltip';
export default function DaisyUIDemo() {
return (
<main className="min-h-screen bg-base-200 py-8">
<div className="max-w-2xl mx-auto space-y-8">
<h1 className="text-3xl font-bold text-base-content mb-2">DaisyUI Component Demo</h1>
<p className="text-base-content/70 mb-6">Test and validate DaisyUI components and theme integration.</p>
{/* Alerts */}
<section>
<h2 className="text-xl font-semibold mb-2">Alerts</h2>
<div className="space-y-2">
<div className="alert alert-info">
<span className="inline-flex items-center justify-center rounded-full bg-info text-info-content w-8 h-8 mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</span>
<span>This is a DaisyUI info alert! 🎉</span>
</div>
<div className="alert alert-success">
<span className="inline-flex items-center justify-center rounded-full bg-success text-success-content w-8 h-8 mr-2">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
<span>DaisyUI is working perfectly with your theme!</span>
</div>
<div className="alert alert-warning">
<span className="inline-flex items-center justify-center rounded-full bg-warning text-warning-content w-8 h-8 mr-2">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</span>
<span>This is a warning alert example</span>
</div>
<div className="alert alert-error">
<span className="inline-flex items-center justify-center rounded-full bg-error text-error-content w-8 h-8 mr-2">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
<span>This is an error alert example</span>
</div>
</div>
</section>
{/* Buttons */}
<section>
<h2 className="text-xl font-semibold mb-2">Buttons</h2>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary">Primary</button>
<button className="btn btn-secondary">Secondary</button>
<button className="btn btn-accent">Accent</button>
<button className="btn btn-info">Info</button>
<button className="btn btn-success">Success</button>
<button className="btn btn-warning">Warning</button>
<button className="btn btn-error">Error</button>
<button className="btn btn-outline">Outline</button>
<button className="btn btn-disabled" disabled>Disabled</button>
</div>
</section>
{/* Cards */}
<section>
<h2 className="text-xl font-semibold mb-2">Cards</h2>
<div className="card w-full bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">DaisyUI Card</h2>
<p>This is a sample card using DaisyUI's card component.</p>
<div className="card-actions justify-end">
<button className="btn btn-primary">Action</button>
</div>
</div>
</div>
</section>
{/* Badges */}
<section>
<h2 className="text-xl font-semibold mb-2">Badges</h2>
<div className="flex flex-wrap gap-2">
<span className="badge badge-primary">Primary</span>
<span className="badge badge-secondary">Secondary</span>
<span className="badge badge-accent">Accent</span>
<span className="badge badge-info">Info</span>
<span className="badge badge-success">Success</span>
<span className="badge badge-warning">Warning</span>
<span className="badge badge-error">Error</span>
</div>
</section>
{/* Tooltip */}
<section>
<h2 className="text-xl font-semibold mb-2">Tooltip</h2>
<Tooltip content="This is a DaisyUI tooltip!">
<button className="btn btn-outline">Hover me</button>
</Tooltip>
</section>
</div>
</main>
);
}

20
src/app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import "../globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "@/components/Providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Pew Builder - Firearm Parts Catalog & Build Management",
description: "Professional firearm parts catalog and AR-15 build management system",
};
export default function MainAppLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
{children}
</Providers>
);
}

View File

@@ -0,0 +1,417 @@
'use client';
import { useState } from 'react';
import SearchInput from '@/components/SearchInput';
// Sample build data
const sampleMyBuilds = [
{
id: '1',
name: 'Budget AR-15 Build',
description: 'A cost-effective AR-15 build using quality budget components',
status: 'completed' as const,
totalCost: 847.50,
completedDate: '2024-01-15',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Budget', '5.56 NATO', '16" Barrel'],
image: 'https://picsum.photos/400/250?random=1'
},
{
id: '2',
name: 'Precision Long Range',
description: 'High-end precision build optimized for long-range accuracy',
status: 'in-progress' as const,
totalCost: 2847.99,
startedDate: '2024-02-01',
components: {
total: 18,
completed: 12,
categories: {
'Upper': 6,
'Lower': 4,
'Accessory': 2
}
},
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=2'
},
{
id: '3',
name: 'Home Defense Setup',
description: 'Compact AR-15 configured for home defense scenarios',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-03-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
image: 'https://picsum.photos/400/250?random=3'
},
{
id: '4',
name: 'Competition Rifle',
description: 'Lightweight competition build for 3-gun matches',
status: 'completed' as const,
totalCost: 1650.75,
completedDate: '2023-12-10',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Competition', '5.56 NATO', '18" Barrel'],
image: 'https://picsum.photos/400/250?random=4'
},
{
id: '5',
name: 'Suppressed SBR',
description: 'Short-barreled rifle build with suppressor integration',
status: 'in-progress' as const,
totalCost: 1895.25,
startedDate: '2024-01-20',
components: {
total: 18,
completed: 8,
categories: {
'Upper': 4,
'Lower': 3,
'Accessory': 1
}
},
tags: ['SBR', 'Suppressed', '300 BLK'],
image: 'https://picsum.photos/400/250?random=5'
},
{
id: '6',
name: 'Retro M16A1 Clone',
description: 'Faithful reproduction of the classic M16A1 rifle',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-04-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Retro', '5.56 NATO', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=6'
}
];
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc';
export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Filter builds
const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false;
}
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
});
// Sort builds
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'totalCost') {
aValue = a.totalCost;
bValue = b.totalCost;
} else if (sortField === 'status') {
aValue = a.status;
bValue = b.status;
} else if (sortField === 'completedDate') {
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const getStatusColor = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
case 'planning':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return '✓';
case 'in-progress':
return '🔄';
case 'planning':
return '📋';
default:
return '❓';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100);
};
const totalMyBuilds = sampleMyBuilds.length;
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
return (
<main className="min-h-screen bg-gray-50">
{/* Page Title */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">My Builds</h1>
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
</div>
</div>
{/* Build Summary */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Value</div>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="in-progress">In Progress</option>
<option value="planning">Planning</option>
</select>
</div>
{/* Sort by */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="completedDate">Date</option>
<option value="name">Name</option>
<option value="totalCost">Cost</option>
<option value="status">Status</option>
</select>
</div>
{/* Sort Direction */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
<select
value={sortDirection}
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
</div>
{/* New Build Button */}
<div className="flex items-end">
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
+ New Build
</button>
</div>
</div>
</div>
</div>
{/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */}
<div className="h-48 bg-gray-200 relative">
<img
src={build.image}
alt={build.name}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
<div className="text-center text-gray-600">
<div className="text-4xl mb-2">🔫</div>
<div className="text-sm font-medium">{build.name}</div>
</div>
</div>
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
{getStatusIcon(build.status)} {build.status}
</span>
</div>
</div>
{/* Build Content */}
<div className="p-6">
{/* Build Title and Date */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
<div className="text-xs text-gray-500">
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress</span>
<span>{build.components.completed}/{build.components.total} components</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getProgressPercentage(build)}%` }}
></div>
</div>
</div>
{/* Component Categories */}
<div className="mb-4">
<div className="flex space-x-2">
{Object.entries(build.components.categories).map(([category, count]) => (
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
{category}: {count}
</span>
))}
</div>
</div>
{/* Tags */}
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{build.tags.map((tag) => (
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
{tag}
</span>
))}
</div>
</div>
{/* Cost and Actions */}
<div className="flex justify-between items-center">
<div className="text-lg font-bold text-gray-900">
${build.totalCost.toFixed(2)}
</div>
<div className="flex space-x-2">
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
View Details
</button>
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
Edit
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-lg font-medium mb-2">No builds found</div>
<div className="text-sm">Try adjusting your filters or create a new build</div>
</div>
</div>
)}
</div>
</main>
);
}

58
src/app/(main)/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
import BetaTester from "@/components/BetaTester";
import Link from 'next/link';
export default function LandingPage() {
return (
<div className="bg-white font-sans">
{/* SVG Grid Background */}
<div className="relative isolate pt-1">
<svg
aria-hidden="true"
className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
>
<defs>
<pattern
id="grid"
width={200}
height={200}
patternUnits="userSpaceOnUse"
>
<path d="M100 200V.5M.5 .5H200" fill="none" />
</pattern>
</defs>
<rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-start lg:gap-x-10 lg:px-8 lg:py-40">
{/* Left: Headline, Subheading, Button */}
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
<h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
A better way to plan your next build
</h1>
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
Welcome to Pew Builder the modern way to discover, compare, and assemble firearm parts. Start your build, explore top brands, and find the perfect components for your next project.
</p>
<div className="mt-10 flex items-top gap-x-6">
<Link
href="/build"
className="btn btn-primary text-base font-semibold px-6"
>
Get Building
</Link>
</div>
</div>
{/* Right: Product Image */}
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center group">
<img
alt="AR-15 Lower Receiver"
src="https://i.imgur.com/IK8FbaI.png"
className="max-w-md w-full h-auto object-contain rounded-xl transition-transform duration-500 ease-in-out animate-float group-hover:-translate-y-2"
style={{ willChange: 'transform' }}
/>
</div>
</div>
</div>
{/* Beta Tester CTA */}
<BetaTester />
</div>
);
}

View File

@@ -0,0 +1,124 @@
// Minimal, future-proof category mapping for builder logic
export const categoryToComponentType: Record<string, string> = {
"Muzzle Devices": "Muzzle Device",
"Receiver Parts": "Lower Receiver",
"Barrel Parts": "Barrel",
"Stock Parts": "Stock",
"Bolt Parts": "Bolt Carrier Group",
"Triggers Parts": "Trigger",
"Sights": "Accessories"
};
// Map category to builder component type, with fallback heuristics
export function mapToBuilderType(category: string): string {
if (categoryToComponentType[category]) {
return categoryToComponentType[category];
}
// Fallback: guess based on keywords
if (category?.toLowerCase().includes('barrel')) return 'Barrel';
if (category?.toLowerCase().includes('stock')) return 'Stock';
if (category?.toLowerCase().includes('bolt')) return 'Bolt Carrier Group';
if (category?.toLowerCase().includes('trigger')) return 'Trigger';
if (category?.toLowerCase().includes('sight') || category?.toLowerCase().includes('optic')) return 'Accessories';
// Log for future mapping
console.warn('Unmapped category:', category);
return 'Accessories';
}
// List of standardized builder component types (from component_type.csv)
export const standardizedComponentTypes = [
"Upper Receiver",
"Barrel",
"Muzzle Device",
"Lower Receiver",
"Safety",
"Trigger",
"Gas Tube",
"Gas Block",
"Grips",
"Handguards",
"Charging Handle",
"Bolt Carrier Group",
"Magazine",
"Buffer Assembly",
"Buffer Tube",
"Foregrips",
"Lower Parts Kit",
"Accessories"
];
// Builder category hierarchy for filters
export const builderCategories = [
{
id: "upper-parts",
name: "Upper Parts",
subcategories: [
{ id: "complete-upper", name: "Complete Upper Receiver" },
{ id: "stripped-upper", name: "Stripped Upper Receiver" },
{ id: "barrel", name: "Barrel" },
{ id: "gas-block", name: "Gas Block" },
{ id: "gas-tube", name: "Gas Tube" },
{ id: "handguard", name: "Handguard / Rail" },
{ id: "bcg", name: "Bolt Carrier Group (BCG)" },
{ id: "charging-handle", name: "Charging Handle" },
{ id: "muzzle-device", name: "Muzzle Device" },
{ id: "forward-assist", name: "Forward Assist" },
{ id: "dust-cover", name: "Dust Cover" }
]
},
{
id: "lower-parts",
name: "Lower Parts",
subcategories: [
{ id: "complete-lower", name: "Complete Lower Receiver" },
{ id: "stripped-lower", name: "Stripped Lower Receiver" },
{ id: "lower-parts-kit", name: "Lower Parts Kit" },
{ id: "trigger", name: "Trigger / Fire Control Group" },
{ id: "buffer-tube", name: "Buffer Tube Assembly" },
{ id: "buffer-spring", name: "Buffer & Spring" },
{ id: "stock", name: "Stock / Brace" },
{ id: "pistol-grip", name: "Pistol Grip" },
{ id: "trigger-guard", name: "Trigger Guard" },
{ id: "ambi-controls", name: "Ambidextrous Controls" }
]
},
{
id: "accessories",
name: "Accessories",
subcategories: [
{ id: "optics", name: "Optics & Sights" },
{ id: "sling-mounts", name: "Sling Mounts / QD Points" },
{ id: "slings", name: "Slings" },
{ id: "grips-bipods", name: "Vertical Grips / Bipods" },
{ id: "lights", name: "Weapon Lights" },
{ id: "magazines", name: "Magazines" },
{ id: "optic-mounts", name: "Optic Mounts / Rings" },
{ id: "suppressors", name: "Suppressors / Adapters" }
]
},
{
id: "kits-bundles",
name: "Kits / Bundles",
subcategories: [
{ id: "rifle-kit", name: "Rifle Kit" },
{ id: "pistol-kit", name: "Pistol Kit" },
{ id: "upper-kit", name: "Upper Build Kit" },
{ id: "lower-kit", name: "Lower Build Kit" },
{ id: "kit-80", name: "80% Build Kit" },
{ id: "receiver-set", name: "Matched Receiver Set" }
]
}
];
// Example subcategory mapping (expand as needed)
export const subcategoryMapping: Record<string, string> = {
"Rifle Barrels": "barrel",
"Bolt Carrier Groups": "bcg",
"Handguards & Rails": "handguard",
"Suppressors": "suppressors",
"Receivers": "complete-upper", // or "stripped-upper" if you want to split
"Triggers": "trigger",
"Rifle Stocks": "stock",
"Buttstocks": "stock",
// ...add more mappings as needed
};

View File

@@ -0,0 +1,847 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip';
import Link from 'next/link';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
// Product type (copied from mock/product for type safety)
type Product = {
id: string;
name: string;
description: string;
longDescription?: string;
image_url: string;
images?: string[];
brand: { id: string; name: string; logo?: string };
category: { id: string; name: string };
subcategory?: string;
offers: Array<{
price: number;
url: string;
vendor: { name: string; logo?: string };
inStock?: boolean;
shipping?: string;
}>;
restrictions?: {
nfa?: boolean;
sbr?: boolean;
suppressor?: boolean;
stateRestrictions?: string[];
};
slug: string;
};
// Restrictions for filter dropdown
const restrictionOptions = [
{ value: '', label: 'All Restrictions' },
{ value: 'NFA', label: 'NFA' },
{ value: 'SBR', label: 'SBR' },
{ value: 'Suppressor', label: 'Suppressor' },
{ value: 'State Restrictions', label: 'State Restrictions' },
];
type SortField = 'name' | 'category' | 'price';
type SortDirection = 'asc' | 'desc';
// Restriction indicator component
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'bg-red-600 text-white',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'bg-orange-600 text-white',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'bg-purple-600 text-white',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'bg-blue-600 text-white',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'bg-yellow-600 text-black',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'bg-pink-600 text-white',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'bg-green-600 text-white',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
};
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
if (!config) return null;
return (
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
);
};
// Tailwind UI Dropdown Component
const Dropdown = ({
label,
value,
onChange,
options,
placeholder = "Select option"
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-zinc-700 mb-1">
{label}
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-zinc-900">
{value || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 text-primary-900' : 'text-zinc-900'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
// Map product categories to checklist component categories
const getComponentCategory = (productCategory: string): string => {
const categoryMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper',
'Barrel': 'Upper',
'BCG': 'Upper',
'Bolt Carrier Group': 'Upper',
'Charging Handle': 'Upper',
'Gas Block': 'Upper',
'Gas Tube': 'Upper',
'Handguard': 'Upper',
'Muzzle Device': 'Upper',
'Suppressor': 'Upper',
// Lower components
'Lower Receiver': 'Lower',
'Trigger': 'Lower',
'Trigger Guard': 'Lower',
'Pistol Grip': 'Lower',
'Buffer Tube': 'Lower',
'Buffer': 'Lower',
'Buffer Spring': 'Lower',
'Stock': 'Lower',
// Accessories
'Magazine': 'Accessory',
'Sights': 'Accessory',
'Optic': 'Accessory',
'Scope': 'Accessory',
'Red Dot': 'Accessory',
};
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
};
// Map product categories to specific checklist component names
const getMatchingComponentName = (productCategory: string): string => {
return categoryToComponentType[productCategory] || '';
};
export default function Home() {
const searchParams = useSearchParams();
const router = useRouter();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [selectedCategoryId, setSelectedCategoryId] = useState('all');
const [selectedSubcategoryId, setSelectedSubcategoryId] = useState('all');
const [selectedBrand, setSelectedBrand] = useState('All');
const [selectedVendor, setSelectedVendor] = useState('All');
const [priceRange, setPriceRange] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [selectedRestriction, setSelectedRestriction] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
const selectedParts = useBuildStore((state) => state.selectedParts);
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
// Fetch live data from /api/test-products
useEffect(() => {
setLoading(true);
fetch('/api/test-products')
.then(res => res.json())
.then(data => {
if (data.success && Array.isArray(data.data)) {
// Map API data to Product type
const mapped: Product[] = data.data.slice(0, 50).map((item: any) => ({
id: item.uuid,
name: item.productName,
description: item.shortDescription || item.longDescription || '',
longDescription: item.longDescription,
image_url: item.imageUrl || item.thumbUrl || '/window.svg',
images: [item.imageUrl, item.thumbUrl].filter(Boolean),
brand: {
id: item.brandName || 'unknown',
name: item.brandName || 'Unknown',
logo: item.brandLogoImage || '',
},
category: {
id: item.category || 'unknown',
name: item.category || 'Unknown',
},
subcategory: item.subcategory,
offers: [
{
price: parseFloat(item.salePrice || item.retailPrice || '0'),
url: item.buyLink || '',
vendor: {
name: 'Brownells', // Static for now, or parse from buyLink if needed
logo: '',
},
inStock: true,
shipping: '',
},
],
restrictions: {}, // Could infer from department/category if needed
slug: item.slug || '',
}));
setProducts(mapped);
// Log unique categories for mapping
const uniqueCategories = Array.from(new Set(mapped.map(p => p.category.name)));
console.log('Unique categories from live data:', uniqueCategories);
} else {
setError('No data returned from API');
}
setLoading(false);
})
.catch(err => {
setError(String(err));
setLoading(false);
});
}, []);
// Extract unique values for dropdowns from live data
const categories = [{ id: 'all', name: 'All Categories' }, ...builderCategories.map(cat => ({ id: cat.id, name: cat.name }))];
const brands = [{ value: 'All', label: 'All Brands' }, ...Array.from(new Set(products.map(part => part.brand.name))).map(name => ({ value: name, label: name }))];
const vendors = [{ value: 'All', label: 'All Vendors' }, ...Array.from(new Set(products.flatMap(part => part.offers.map(offer => offer.vendor.name)))).map(name => ({ value: name, label: name }))];
// Read category from URL parameter on page load
useEffect(() => {
const categoryParam = searchParams.get('category');
if (categoryParam && categories.some(c => c.id === categoryParam)) {
setSelectedCategoryId(categoryParam);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, categories.map(c => c.id).join(',')]);
const selectedCategory = builderCategories.find(cat => cat.id === selectedCategoryId);
const subcategoryOptions = selectedCategory
? [{ id: 'all', name: 'All Subcategories' }, ...selectedCategory.subcategories]
: [];
// Filter parts based on selected criteria
const filteredParts = products.filter(part => {
const mappedSubcat = subcategoryMapping[part.subcategory || ''];
const matchesCategory = selectedCategoryId === 'all' || (selectedCategory && selectedCategory.subcategories.some(sub => sub.id === mappedSubcat));
const matchesSubcategory = selectedSubcategoryId === 'all' || mappedSubcat === selectedSubcategoryId;
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
const matchesSearch = !searchTerm ||
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
// Restriction filter logic (no real data, so always true)
let matchesRestriction = true;
if (selectedRestriction) {
matchesRestriction = false;
}
// Price range filtering
let matchesPrice = true;
if (priceRange) {
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
switch (priceRange) {
case 'under-100':
matchesPrice = lowestPrice < 100;
break;
case '100-300':
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
break;
case '300-500':
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
break;
case 'over-500':
matchesPrice = lowestPrice > 500;
break;
}
}
return matchesCategory && matchesSubcategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
});
// Sort parts
const sortedParts = [...filteredParts].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'price') {
aValue = Math.min(...a.offers.map(offer => offer.price));
bValue = Math.min(...b.offers.map(offer => offer.price));
} else if (sortField === 'category') {
aValue = a.category.name.toLowerCase();
bValue = b.category.name.toLowerCase();
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return '↕️';
}
return sortDirection === 'asc' ? '↑' : '↓';
};
const clearFilters = () => {
setSelectedCategoryId('all');
setSelectedSubcategoryId('all');
setSelectedBrand('All');
setSelectedVendor('All');
setSearchTerm('');
setPriceRange('');
setSelectedRestriction('');
};
const hasActiveFilters = selectedCategoryId !== 'all' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
const flags: string[] = [];
if (restrictions?.nfa) flags.push('NFA');
if (restrictions?.sbr) flags.push('SBR');
if (restrictions?.suppressor) flags.push('Suppressor');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
return flags;
};
const handleAdd = (part: Product) => {
setAddedPartIds((prev) => [...prev, part.id]);
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
};
useEffect(() => {
if (products.length) {
const uniqueCategories = Array.from(new Set(products.map(p => p.category?.name)));
const uniqueSubcategories = Array.from(new Set(products.map(p => p.subcategory)));
console.log('Unique categories:', uniqueCategories);
console.log('Unique subcategories:', uniqueSubcategories);
}
}, [products]);
return (
<main className="min-h-screen bg-zinc-50">
{/* Page Title */}
<div className="bg-white border-b border-zinc-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-zinc-900">
Parts Catalog
{selectedCategory && selectedCategoryId !== 'all' && (
<span className="text-primary-600 ml-2 text-2xl">
- {selectedCategory.name}
</span>
)}
</h1>
<p className="text-zinc-600 mt-2">
{selectedCategory && selectedCategoryId !== 'all'
? `Showing ${selectedCategory.name} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b border-zinc-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Search Row */}
<div className="mb-3 flex justify-end">
<div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
{isSearchExpanded ? (
<div className="flex items-center gap-2 w-full justify-end">
<div className="flex-1 max-w-md">
<SearchInput
label=""
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search parts..."
/>
</div>
<button
onClick={() => {
setIsSearchExpanded(false);
setSearchTerm('');
}}
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors flex-shrink-0"
aria-label="Close search"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
) : (
<button
onClick={() => setIsSearchExpanded(true)}
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors rounded-lg hover:bg-zinc-100"
aria-label="Open search"
>
<MagnifyingGlassIcon className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Filters Row */}
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
{/* Category Dropdown */}
<div className="col-span-1">
<Dropdown
label="Category"
value={selectedCategoryId}
onChange={setSelectedCategoryId}
options={categories.map(c => ({ value: c.id, label: c.name }))}
placeholder="All categories"
/>
</div>
{/* Subcategory Dropdown (only if a category is selected) */}
{selectedCategory && selectedCategoryId !== 'all' && (
<div className="col-span-1">
<Dropdown
label="Subcategory"
value={selectedSubcategoryId}
onChange={setSelectedSubcategoryId}
options={subcategoryOptions.map(s => ({ value: s.id, label: s.name }))}
placeholder="All subcategories"
/>
</div>
)}
{/* Brand Dropdown */}
<div className="col-span-1">
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
</div>
{/* Vendor Dropdown */}
<div className="col-span-1">
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
</div>
{/* Price Range */}
<div className="col-span-1">
<Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-zinc-700 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-zinc-900">
{priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' :
priceRange === '100-300' ? '$100 - $300' :
priceRange === '300-500' ? '$300 - $500' :
priceRange === 'over-500' ? '$500+' : priceRange}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{[
{ value: '', label: 'Select price range' },
{ value: 'under-100', label: 'Under $100' },
{ value: '100-300', label: '$100 - $300' },
{ value: '300-500', label: '$300 - $500' },
{ value: 'over-500', label: '$500+' }
].map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 text-primary-900' : 'text-zinc-900'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<div className="col-span-1">
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
</div>
{/* Clear Filters */}
<div className="col-span-1 flex items-end">
<button
onClick={clearFilters}
disabled={!hasActiveFilters}
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 text-white'
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-3.5 w-3.5" />
Clear All
</button>
</div>
</div>
</div>
</div>
{/* Parts Display */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* View Toggle and Results Count */}
<div className="flex justify-between items-center mb-6">
<div className="text-sm text-zinc-700">
{loading ? 'Loading...' : `Showing ${sortedParts.length} of ${products.length} parts`}
{hasActiveFilters && !loading && (
<span className="ml-2 text-primary-600">
(filtered)
</span>
)}
{error && <span className="ml-2 text-red-500">{error}</span>}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-600">View:</span>
<div className="btn-group">
<button
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
aria-label="Card view"
>
<Squares2X2Icon className="h-5 w-5" />
</button>
</div>
</div>
</div>
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-zinc-200">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-zinc-200">
<thead className="bg-zinc-50 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:bg-zinc-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-zinc-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-zinc-200">
{sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-zinc-50 transition-colors">
<td className="px-0 py-2 flex items-center gap-2 align-top">
<div className="w-12 h-12 flex-shrink-0 rounded bg-zinc-100 overflow-hidden flex items-center justify-center border border-zinc-200">
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
</div>
<div className="max-w-md break-words whitespace-normal">
<Link href={`/products/${part.slug}`} className="text-sm font-semibold text-primary hover:underline">
{part.name}
</Link>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-zinc-900">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{(() => {
// Find if this part is already selected for any component
const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
// Find the appropriate component based on category match
const matchingComponentName = getMatchingComponentName(part.category.name);
const matchingComponent = (buildGroups as {components: any[]}[]).flatMap((group) => group.components).find((component: any) =>
component.name === matchingComponentName && !selectedParts[component.id]
);
if (selectedComponentId) {
return (
<button
className="btn btn-outline btn-sm"
onClick={() => removePartForComponent(selectedComponentId)}
>
Remove
</button>
);
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
return (
<button
className="btn btn-neutral btn-sm flex items-center gap-1"
onClick={() => {
selectPartForComponent(matchingComponent.id, {
id: part.id,
name: part.name,
image_url: part.image_url,
brand: part.brand,
category: part.category,
offers: part.offers,
});
router.push('/build');
}}
>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</button>
);
} else {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-200 text-gray-500 text-xs">N/A</span>
);
}
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-zinc-50 px-6 py-3 border-t border-zinc-200">
<div className="flex items-center justify-between">
<div className="text-sm text-zinc-700">
Showing {sortedParts.length} of {products.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600">
(filtered)
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedParts.map((part) => (
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
))}
</div>
)}
</div>
{/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-zinc-200">
<div className="flex items-center justify-center gap-4 text-xs text-zinc-500">
<span className="font-medium">Restrictions:</span>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
<span>National Firearms Act</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
<span>Short Barrel Rifle</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
<span>Sound Suppressor</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
<span>FFL Required</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺State</div>
<span>State Restrictions</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
<span>High Capacity</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
<span>SilencerShop Partner</span>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import RestrictionAlert from '@/components/RestrictionAlert';
import { StarIcon } from '@heroicons/react/20/solid';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
// Product type (copied from /parts/page.tsx for type safety)
type Product = {
id: string;
name: string;
description: string;
longDescription?: string;
image_url: string;
images?: string[];
brand: { id: string; name: string; logo?: string };
category: { id: string; name: string };
subcategory?: string;
offers: Array<{
price: number;
url: string;
vendor: { name: string; logo?: string };
inStock?: boolean;
shipping?: string;
}>;
restrictions?: {
nfa?: boolean;
sbr?: boolean;
suppressor?: boolean;
stateRestrictions?: string[];
};
};
export default function ProductDetailsPage() {
const params = useParams();
const slug = params.slug as string;
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [selectedOffer, setSelectedOffer] = useState(0);
const [addSuccess, setAddSuccess] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
useEffect(() => {
setLoading(true);
fetch(`/api/products/${slug}`)
.then(res => res.json())
.then((data: any) => {
if (data.success && data.product) {
setProduct(data.product);
} else {
setError('No data returned from API');
}
setLoading(false);
})
.catch((err: any) => {
setError(String(err));
setLoading(false);
});
}, [slug]);
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="alert alert-info">
<span>Loading product...</span>
</div>
</div>
);
}
if (!product) {
return (
<div className="container mx-auto px-4 py-8">
<div className="alert alert-error">
<span>Product not found</span>
</div>
</div>
);
}
const allImages = product.images && product.images.length > 0
? product.images
: [product.image_url];
const lowestPrice = Math.min(...product.offers.map(o => o.price));
const highestPrice = Math.max(...product.offers.map(o => o.price));
const handleAddToBuild = () => {
// Map category to component ID (can be improved to match /parts logic)
const categoryToComponentMap = {
'Barrel': 'barrel',
'Upper Receiver': 'upper',
'Suppressor': 'suppressor',
'BCG': 'bcg',
'Charging Handle': 'charging-handle',
'Handguard': 'handguard',
'Gas Block': 'gas-block',
'Gas Tube': 'gas-tube',
'Muzzle Device': 'muzzle-device',
'Lower Receiver': 'lower',
'Trigger': 'trigger',
'Pistol Grip': 'pistol-grip',
'Buffer Tube': 'buffer-tube',
'Buffer': 'buffer',
'Buffer Spring': 'buffer-spring',
'Stock': 'stock',
'Magazine': 'magazine',
'Sights': 'sights'
};
const componentId = (categoryToComponentMap as Record<string, string>)[product.category.name] || product.category.id;
selectPartForComponent(componentId, {
id: product.id,
name: product.name,
image_url: product.image_url,
brand: product.brand,
category: product.category,
offers: product.offers
});
setAddSuccess(true);
setTimeout(() => setAddSuccess(false), 1500);
};
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<div className="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/parts">Parts</a></li>
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
<li>{product.name}</li>
</ul>
</div>
{/* Restriction Alert */}
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
<RestrictionAlert
type="warning"
title="Restricted Item"
message="This item may have federal or state restrictions. Please ensure compliance with all applicable laws and regulations."
icon="🔒"
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Product Images */}
<div className="space-y-4">
<div className="aspect-square bg-zinc-100 dark:bg-zinc-800 rounded-lg overflow-hidden">
<Image
src={allImages[selectedImageIndex]}
alt={product.name}
width={600}
height={600}
className="w-full h-full object-cover"
/>
</div>
{allImages.length > 1 && (
<div className="flex gap-2 overflow-x-auto">
{allImages.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImageIndex(index)}
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${
selectedImageIndex === index
? 'border-primary-500'
: 'border-zinc-200 dark:border-zinc-700'
}`}
>
<Image
src={image}
alt={`${product.name} view ${index + 1}`}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
{/* Product Info */}
<div className="space-y-6">
{/* Brand & Category */}
<div className="flex items-center gap-4">
{product.brand.logo && (
<Image
src={product.brand.logo}
alt={product.brand.name}
width={100}
height={50}
className="h-8 w-auto"
/>
)}
<div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.brand.name}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.category.name}
</div>
</div>
</div>
{/* Product Name */}
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">
{product.name}
</h1>
{/* Price Range */}
<div className="flex items-center gap-4">
<div className="text-3xl font-bold text-primary-600">
${lowestPrice.toFixed(2)}
</div>
{lowestPrice !== highestPrice && (
<div className="text-lg text-zinc-600 dark:text-zinc-400">
- ${highestPrice.toFixed(2)}
</div>
)}
<div className="text-sm text-zinc-500">
from {product.offers.length} vendor{product.offers.length > 1 ? 's' : ''}
</div>
</div>
{/* Description */}
<div>
<h3 className="text-lg font-semibold mb-2">Description</h3>
<p className="text-zinc-700 dark:text-zinc-300">
{product.longDescription || product.description}
</p>
</div>
{/* Add to Build Button */}
<div className="flex gap-4">
<button className="btn btn-primary flex-1" onClick={handleAddToBuild}>
Add to Current Build
</button>
<button className="btn btn-outline">
Save for Later
</button>
</div>
{addSuccess && (
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
)}
</div>
</div>
{/* Vendor Offers */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Where to Buy</h2>
<div className="space-y-4">
{product.offers.map((offer, index) => (
<div key={index} className="card">
<div className="card-body">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{offer.vendor.logo && (
<Image
src={offer.vendor.logo}
alt={offer.vendor.name}
width={80}
height={40}
className="h-8 w-auto"
/>
)}
<div>
<div className="font-semibold">{offer.vendor.name}</div>
{offer.shipping && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{offer.shipping}
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-2xl font-bold text-primary-600">
${offer.price.toFixed(2)}
</div>
{offer.inStock !== undefined && (
<div className={`text-sm ${offer.inStock ? 'text-success' : 'text-error'}`}>
{offer.inStock ? 'In Stock' : 'Out of Stock'}
</div>
)}
</div>
<a
href={offer.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
View Deal
</a>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { mockProducts } from '@/mock/product';
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>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
export default function TestProductsPage() {
const [products, setProducts] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/test-products')
.then(res => res.json())
.then(data => {
if (data.success) setProducts(data.data);
else setError(data.error || "Unknown error");
})
.catch(err => setError(String(err)));
}, []);
return (
<div className="max-w-2xl mx-auto py-10">
<h1 className="text-2xl font-bold mb-4">Test Products API</h1>
{error && <div className="text-red-500 mb-4">Error: {error}</div>}
<pre className="bg-gray-100 p-4 rounded overflow-x-auto text-xs">
{JSON.stringify(products, null, 2)}
</pre>
</div>
);
}