mirror of
https://gitea.gofwd.group/sean/gunbuilder-next-tailwind.git
synced 2025-12-06 02:56:45 -05:00
clean up stuff
This commit is contained in:
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
|
<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">
|
<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/>
|
Enter your email address and we'll send you a link to reset your password.<br/>
|
||||||
<span className="text-blue-600 font-semibold">(This feature is not yet implemented.)</span>
|
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
<input
|
||||||
@@ -38,7 +38,7 @@ export default function ForgotPasswordPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Back to login</Link>
|
<Link href="/account/login" className="text-primary hover:underline text-sm">Back to login</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
|||||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
|
<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">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
Or{' '}
|
Or{' '}
|
||||||
<Link href="/account/register" className="font-medium text-blue-600 hover:text-blue-500">
|
<Link href="/account/register" className="font-medium text-primary hover:text-blue-500">
|
||||||
Sign Up For Free
|
Sign Up For Free
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
@@ -109,7 +109,7 @@ export default function LoginPage() {
|
|||||||
id="remember-me"
|
id="remember-me"
|
||||||
name="remember-me"
|
name="remember-me"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="h-4 w-4 text-primary focus:ring-blue-500 border-gray-300 rounded"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||||
@@ -118,7 +118,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<Link href="/account/forgot-password" className="font-medium text-blue-600 hover:text-blue-500">
|
<Link href="/account/forgot-password" className="font-medium text-primary hover:text-blue-500">
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function RegisterPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Already have an account? Sign in</Link>
|
<Link href="/account/login" className="text-primary hover:underline text-sm">Already have an account? Sign in</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ const getProductCategoryForComponent = (componentName: string): string => {
|
|||||||
return componentMap[componentName] || 'Lower Receiver';
|
return componentMap[componentName] || 'Lower Receiver';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add a slugify helper at the top of the file
|
||||||
|
const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||||
|
|
||||||
export { buildGroups };
|
export { buildGroups };
|
||||||
export default function BuildPage() {
|
export default function BuildPage() {
|
||||||
const [sortField, setSortField] = useState<SortField>('name');
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
@@ -404,7 +407,7 @@ export default function BuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
|
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
|
<div className="text-2xl font-bold text-primary">${actualTotalCost.toFixed(2)}</div>
|
||||||
<div className="text-sm text-gray-500">Total Cost</div>
|
<div className="text-sm text-gray-500">Total Cost</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -616,9 +619,6 @@ export default function BuildPage() {
|
|||||||
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Selected Product
|
Selected Product
|
||||||
</th>
|
</th>
|
||||||
@@ -638,7 +638,7 @@ export default function BuildPage() {
|
|||||||
<React.Fragment key={group.name}>
|
<React.Fragment key={group.name}>
|
||||||
{/* Group Header */}
|
{/* Group Header */}
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
<td colSpan={7} className="px-6 py-2">
|
<td colSpan={5} className="px-6 py-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
|
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
|
||||||
@@ -673,7 +673,7 @@ export default function BuildPage() {
|
|||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${selected.id}`}
|
href={`/products/${selected.id}`}
|
||||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
className="text-primary hover:text-blue-800 hover:underline"
|
||||||
>
|
>
|
||||||
{selected.name}
|
{selected.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -709,11 +709,6 @@ export default function BuildPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<button
|
<button
|
||||||
@@ -724,7 +719,7 @@ export default function BuildPage() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
|
href={`/parts/${slugify(getProductCategoryForComponent(component.name))}`}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Find Parts
|
Find Parts
|
||||||
@@ -739,7 +734,7 @@ export default function BuildPage() {
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-12 text-center">
|
<td colSpan={5} className="px-6 py-12 text-center">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
<div className="text-lg font-medium mb-2">No components found</div>
|
<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 className="text-sm">Try adjusting your filters or search terms</div>
|
||||||
@@ -757,7 +752,7 @@ export default function BuildPage() {
|
|||||||
<div className="text-sm text-gray-700">
|
<div className="text-sm text-gray-700">
|
||||||
Showing {sortedComponents.length} of {allComponents.length} components
|
Showing {sortedComponents.length} of {allComponents.length} components
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="ml-2 text-blue-600">
|
<span className="ml-2 text-primary">
|
||||||
(filtered)
|
(filtered)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
|
|||||||
<div className="text-sm text-gray-500">Completed</div>
|
<div className="text-sm text-gray-500">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
<div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
|
||||||
<div className="text-sm text-gray-500">In Progress</div>
|
<div className="text-sm text-gray-500">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
|
|||||||
<div className="text-sm text-gray-500">Completed</div>
|
<div className="text-sm text-gray-500">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
<div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
|
||||||
<div className="text-sm text-gray-500">In Progress</div>
|
<div className="text-sm text-gray-500">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function LandingPage() {
|
|||||||
<div className="mt-10 flex items-top gap-x-6">
|
<div className="mt-10 flex items-top gap-x-6">
|
||||||
<Link
|
<Link
|
||||||
href="/build"
|
href="/build"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white text-base font-semibold px-6 py-3 rounded-md transition-colors"
|
className="bg-gray-900 hover:bg-gray-950 text-white text-base font-semibold px-6 py-3 transition-colors"
|
||||||
>
|
>
|
||||||
Get Building
|
Get Building
|
||||||
</Link>
|
</Link>
|
||||||
@@ -52,6 +52,7 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Beta Tester CTA */}
|
{/* Beta Tester CTA */}
|
||||||
|
|
||||||
<BetaTester />
|
<BetaTester />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
186
src/app/(main)/parts/[category]/page.tsx
Normal file
186
src/app/(main)/parts/[category]/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
'brandName',
|
||||||
|
'productName',
|
||||||
|
'department',
|
||||||
|
'category',
|
||||||
|
'subcategory',
|
||||||
|
'retailPrice',
|
||||||
|
'salePrice',
|
||||||
|
'imageUrl',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PartsCategoryPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const categoryParam = params?.category as string;
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [brand, setBrand] = useState('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [subcategory, setSubcategory] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`/api/products?page=1&limit=10000`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setProducts(data.data || []);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError(err.message || 'Error fetching products');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get unique filter options from all products in this category
|
||||||
|
const filteredByCategory = useMemo(() => {
|
||||||
|
if (!categoryParam) return [];
|
||||||
|
// Normalize category param to slug (kebab-case, lowercased)
|
||||||
|
const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||||
|
const paramSlug = slugify(categoryParam);
|
||||||
|
return products.filter(p => {
|
||||||
|
const cat = p.category || '';
|
||||||
|
const catSlug = slugify(cat);
|
||||||
|
// Match by slug, by lowercased, or by plural/singular
|
||||||
|
return (
|
||||||
|
catSlug === paramSlug ||
|
||||||
|
cat.toLowerCase() === categoryParam.toLowerCase() ||
|
||||||
|
cat.toLowerCase() === categoryParam.toLowerCase().replace(/s$/, '') ||
|
||||||
|
cat.toLowerCase().replace(/s$/, '') === categoryParam.toLowerCase()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [products, categoryParam]);
|
||||||
|
|
||||||
|
const brandOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.brandName).filter(Boolean))).sort(), [filteredByCategory]);
|
||||||
|
const departmentOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.department).filter(Boolean))).sort(), [filteredByCategory]);
|
||||||
|
const subcategoryOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.subcategory).filter(Boolean))).sort(), [filteredByCategory]);
|
||||||
|
|
||||||
|
// Further filter by sidebar filters
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
return filteredByCategory.filter(p =>
|
||||||
|
(!brand || p.brandName === brand) &&
|
||||||
|
(!department || p.department === department) &&
|
||||||
|
(!subcategory || p.subcategory === subcategory)
|
||||||
|
);
|
||||||
|
}, [filteredByCategory, brand, department, subcategory]);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalPages = Math.ceil(filteredProducts.length / limit);
|
||||||
|
const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
|
||||||
|
|
||||||
|
// Reset to page 1 when filters change
|
||||||
|
useEffect(() => { setPage(1); }, [brand, department, subcategory, limit, categoryParam]);
|
||||||
|
|
||||||
|
// If category is not found
|
||||||
|
if (!categoryParam) {
|
||||||
|
return <div className="max-w-7xl mx-auto px-4 py-8 text-red-600 font-bold">Category not found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-50">
|
||||||
|
<div className="max-w-7xl mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
|
||||||
|
{/* Debug block */}
|
||||||
|
<div className="mb-4 p-2 bg-yellow-100 text-xs text-yellow-900 rounded">
|
||||||
|
<div>categoryParam: <b>{categoryParam}</b></div>
|
||||||
|
<div>First 5 categories in products: {[...new Set(products.map(p => p.category))].slice(0,5).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
{/* Sidebar Filters */}
|
||||||
|
<aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Department</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={department} onChange={e => setDepartment(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/* Products Table */}
|
||||||
|
<section className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 capitalize">{categoryParam} Parts</h1>
|
||||||
|
<div className="overflow-x-auto border rounded bg-white">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||||
|
) : paginatedProducts.length === 0 ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||||
|
) : (
|
||||||
|
paginatedProducts.map((product, i) => (
|
||||||
|
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||||
|
{col === 'imageUrl' && product[col] ? (
|
||||||
|
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
|
||||||
|
) : (
|
||||||
|
product[col] ?? ''
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>Prev</button>
|
||||||
|
<span>Page {page} of {totalPages || 1}</span>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>Next</button>
|
||||||
|
<select
|
||||||
|
className="ml-4 border rounded px-2 py-1"
|
||||||
|
value={limit}
|
||||||
|
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||||
|
>
|
||||||
|
{[25, 50, 100, 200].map(opt => (
|
||||||
|
<option key={opt} value={opt}>{opt} / page</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
import { Listbox, Transition } from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||||
@@ -12,7 +12,6 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useBuildStore } from '@/store/useBuildStore';
|
import { useBuildStore } from '@/store/useBuildStore';
|
||||||
import { buildGroups } from '../build/page';
|
import { buildGroups } from '../build/page';
|
||||||
import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
|
|
||||||
|
|
||||||
// Product type (copied from mock/product for type safety)
|
// Product type (copied from mock/product for type safety)
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -170,7 +169,7 @@ const Dropdown = ({
|
|||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600">
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
|
||||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -222,11 +221,6 @@ const getComponentCategory = (productCategory: string): string => {
|
|||||||
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
|
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] || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pagination Component
|
// Pagination Component
|
||||||
const Pagination = ({
|
const Pagination = ({
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -344,640 +338,218 @@ const Pagination = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home() {
|
// --- Canonical Category Fetch ---
|
||||||
const searchParams = useSearchParams();
|
const useCanonicalCategories = () => {
|
||||||
const router = useRouter();
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
fetch('/api/product-categories')
|
||||||
fetch('/api/test-products')
|
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && Array.isArray(data.data)) {
|
setCategories(data.data);
|
||||||
// Map API data to Product type
|
setLoading(false);
|
||||||
const mapped: Product[] = data.data.slice(0, 50).map((item: any) => ({
|
});
|
||||||
id: item.uuid,
|
}, []);
|
||||||
name: item.productName,
|
return { categories, loading };
|
||||||
description: item.shortDescription || item.longDescription || '',
|
};
|
||||||
longDescription: item.longDescription,
|
|
||||||
image_url: item.imageUrl || item.thumbUrl || '/window.svg',
|
// --- Flatten category tree for dropdowns ---
|
||||||
images: [item.imageUrl, item.thumbUrl].filter(Boolean),
|
function flattenCategories(categories: any[], parent: any = null, depth = 0): any[] {
|
||||||
brand: {
|
let result: any[] = [];
|
||||||
id: item.brandName || 'unknown',
|
for (const cat of categories) {
|
||||||
name: item.brandName || 'Unknown',
|
result.push({ ...cat, depth, parent });
|
||||||
logo: item.brandLogoImage || '',
|
if (cat.children && cat.children.length > 0) {
|
||||||
},
|
result = result.concat(flattenCategories(cat.children, cat, depth + 1));
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: Get all descendant category IDs ---
|
||||||
|
function getDescendantCategoryIds(categories: any[], selectedId: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
function traverse(nodes: any[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (String(node.id) === selectedId) {
|
||||||
|
collect(node);
|
||||||
|
} else if (node.children && node.children.length > 0) {
|
||||||
|
traverse(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function collect(node: any) {
|
||||||
|
result.push(String(node.id));
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
collect(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(categories);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
'brandName',
|
||||||
|
'productName',
|
||||||
|
'department',
|
||||||
|
'category',
|
||||||
|
'subcategory',
|
||||||
|
'retailPrice',
|
||||||
|
'salePrice',
|
||||||
|
'imageUrl',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PartsPage() {
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [brand, setBrand] = useState('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [subcategory, setSubcategory] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`/api/products?page=1&limit=10000`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setProducts(data.data || []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setError(String(err));
|
setError(err.message || 'Error fetching products');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Extract unique values for dropdowns from live data
|
// Get unique filter options from all products
|
||||||
const categories = [{ id: 'all', name: 'All Categories' }, ...builderCategories.map(cat => ({ id: cat.id, name: cat.name }))];
|
const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
|
||||||
const brands = [{ value: 'All', label: 'All Brands' }, ...Array.from(new Set(products.map(part => part.brand.name))).map(name => ({ value: name, label: name }))];
|
const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
|
||||||
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 }))];
|
const categoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.category).filter(Boolean))).sort(), [products]);
|
||||||
|
const subcategoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.subcategory).filter(Boolean))).sort(), [products]);
|
||||||
|
|
||||||
// Read category from URL parameter on page load
|
// Filter products before rendering
|
||||||
useEffect(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
const categoryParam = searchParams.get('category');
|
return products.filter(p =>
|
||||||
if (categoryParam && categories.some(c => c.id === categoryParam)) {
|
(!brand || p.brandName === brand) &&
|
||||||
setSelectedCategoryId(categoryParam);
|
(!department || p.department === department) &&
|
||||||
}
|
(!category || p.category === category) &&
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
(!subcategory || p.subcategory === subcategory)
|
||||||
}, [searchParams, categories.map(c => c.id).join(',')]);
|
);
|
||||||
|
}, [products, brand, department, category, subcategory]);
|
||||||
|
|
||||||
const selectedCategory = builderCategories.find(cat => cat.id === selectedCategoryId);
|
// Pagination
|
||||||
const subcategoryOptions = selectedCategory
|
const totalPages = Math.ceil(filteredProducts.length / limit);
|
||||||
? [{ id: 'all', name: 'All Subcategories' }, ...selectedCategory.subcategories]
|
const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
|
||||||
: [];
|
|
||||||
|
|
||||||
// Filter parts based on selected criteria
|
// Reset to page 1 when filters change
|
||||||
const filteredParts = products.filter(part => {
|
useEffect(() => { setPage(1); }, [brand, department, category, subcategory, limit]);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination logic
|
|
||||||
const totalPages = Math.ceil(sortedParts.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
const paginatedParts = sortedParts.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
// Reset to first page when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [selectedCategoryId, selectedSubcategoryId, selectedBrand, selectedVendor, searchTerm, priceRange, selectedRestriction, sortField, sortDirection]);
|
|
||||||
|
|
||||||
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 (
|
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-blue-100 text-blue-900' : 'text-zinc-900'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
<>
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
<main className="min-h-screen bg-zinc-50">
|
||||||
{option.label}
|
<div className="max-w-full mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
|
||||||
</span>
|
{/* Sidebar Filters */}
|
||||||
{selected ? (
|
<aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
|
<div className="space-y-6">
|
||||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
<div>
|
||||||
</span>
|
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||||
) : null}
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||||
</>
|
<option value="">All</option>
|
||||||
)}
|
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
</Listbox.Option>
|
</select>
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</Listbox>
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Department</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={department} onChange={e => setDepartment(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Restriction Filter */}
|
<label className="block text-xs font-semibold mb-1">Category</label>
|
||||||
<div className="col-span-1">
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={category} onChange={e => setCategory(e.target.value)}>
|
||||||
<Dropdown
|
<option value="">All</option>
|
||||||
label="Restriction"
|
{categoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
value={selectedRestriction}
|
</select>
|
||||||
onChange={setSelectedRestriction}
|
|
||||||
options={restrictionOptions}
|
|
||||||
placeholder="All restrictions"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Clear Filters */}
|
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||||
<div className="col-span-1 flex items-end">
|
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||||
<button
|
<option value="">All</option>
|
||||||
onClick={clearFilters}
|
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
disabled={!hasActiveFilters}
|
</select>
|
||||||
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>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
{/* Products Table */}
|
||||||
|
<section className="flex-1">
|
||||||
{/* Parts Display */}
|
<div className="overflow-x-auto border rounded bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<table className="min-w-full text-sm">
|
||||||
{/* View Toggle and Results Count */}
|
<thead>
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div className="text-sm text-zinc-700">
|
|
||||||
{loading ? 'Loading...' : `Showing ${startIndex + 1}-${Math.min(endIndex, sortedParts.length)} of ${sortedParts.length} parts`}
|
|
||||||
{hasActiveFilters && !loading && (
|
|
||||||
<span className="ml-2 text-blue-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="flex">
|
|
||||||
<button
|
|
||||||
className={`px-3 py-1 text-sm border rounded-l ${viewMode === 'table' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
aria-label="Table view"
|
|
||||||
>
|
|
||||||
<TableCellsIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-3 py-1 text-sm border border-l-0 rounded-r ${viewMode === 'cards' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
|
|
||||||
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>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
|
{columns.map(col => (
|
||||||
Product
|
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||||
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-zinc-200">
|
<tbody>
|
||||||
{paginatedParts.map((part) => (
|
{loading ? (
|
||||||
<tr key={part.id} className="hover:bg-zinc-50 transition-colors">
|
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||||
<td className="px-0 py-2 flex items-center gap-2 align-top">
|
) : paginatedProducts.length === 0 ? (
|
||||||
<div className="w-12 h-12 flex-shrink-0 rounded bg-zinc-100 overflow-hidden flex items-center justify-center border border-zinc-200">
|
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||||
<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>
|
paginatedProducts.map((product, i) => (
|
||||||
<div className="max-w-md break-words whitespace-normal">
|
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
|
||||||
<Link href={`/products/${part.slug}`} className="text-sm font-semibold text-blue-600 hover:underline">
|
{columns.map(col => (
|
||||||
{part.name}
|
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||||
</Link>
|
{col === 'imageUrl' && product[col] ? (
|
||||||
</div>
|
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
|
||||||
|
) : (
|
||||||
|
product[col] ?? ''
|
||||||
|
)}
|
||||||
</td>
|
</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">
|
|
||||||
{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="border border-gray-300 hover:bg-gray-50 text-gray-700 px-3 py-1 rounded-md text-sm font-medium transition-colors"
|
|
||||||
onClick={() => removePartForComponent(selectedComponentId)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors 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>
|
|
||||||
))}
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Pagination Controls */}
|
||||||
</div>
|
<div className="flex items-center gap-4 mt-4">
|
||||||
)}
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
{/* Pagination */}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
{totalPages > 1 && (
|
disabled={page === 1}
|
||||||
<Pagination
|
>Prev</button>
|
||||||
currentPage={currentPage}
|
<span>Page {page} of {totalPages || 1}</span>
|
||||||
totalPages={totalPages}
|
<button
|
||||||
onPageChange={setCurrentPage}
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
itemsPerPage={itemsPerPage}
|
onClick={() => setPage(p => p + 1)}
|
||||||
onItemsPerPageChange={(items) => {
|
disabled={page >= totalPages}
|
||||||
setItemsPerPage(items);
|
>Next</button>
|
||||||
setCurrentPage(1); // Reset to first page when changing items per page
|
<select
|
||||||
}}
|
className="ml-4 border rounded px-2 py-1"
|
||||||
totalItems={sortedParts.length}
|
value={limit}
|
||||||
startIndex={startIndex}
|
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||||
endIndex={endIndex}
|
>
|
||||||
/>
|
{[25, 50, 100, 200].map(opt => (
|
||||||
)}
|
<option key={opt} value={opt}>{opt} / page</option>
|
||||||
|
|
||||||
{/* Card View */}
|
|
||||||
{viewMode === 'cards' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{paginatedParts.map((part) => (
|
|
||||||
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
|
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||||
import { StarIcon } from '@heroicons/react/20/solid';
|
import { StarIcon, HomeIcon } from '@heroicons/react/20/solid';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useBuildStore } from '@/store/useBuildStore';
|
import { useBuildStore } from '@/store/useBuildStore';
|
||||||
|
|
||||||
@@ -82,6 +82,13 @@ export default function ProductDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Breadcrumb pages array
|
||||||
|
const pages = [
|
||||||
|
{ name: 'Parts', href: '/parts', current: false },
|
||||||
|
{ name: product.category.name, href: `/parts?category=${encodeURIComponent(product.category.name)}`, current: false },
|
||||||
|
{ name: product.name, href: '#', current: true },
|
||||||
|
];
|
||||||
|
|
||||||
const allImages = product.images && product.images.length > 0
|
const allImages = product.images && product.images.length > 0
|
||||||
? product.images
|
? product.images
|
||||||
: [product.image_url];
|
: [product.image_url];
|
||||||
@@ -126,14 +133,48 @@ export default function ProductDetailsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="text-sm breadcrumbs mb-6">
|
<nav aria-label="Breadcrumb" className="flex mb-4">
|
||||||
<ul>
|
<ol role="list" className="flex space-x-2 rounded px-2 py-1 ">
|
||||||
<li><a href="/">Home</a></li>
|
<li className="flex">
|
||||||
<li><a href="/parts">Parts</a></li>
|
<div className="flex items-center">
|
||||||
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
|
<a href="/" className="text-zinc-900 hover:text-primary">
|
||||||
<li>{product.name}</li>
|
<HomeIcon aria-hidden="true" className="w-4 h-4 shrink-0" />
|
||||||
</ul>
|
<span className="sr-only">Home</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
{pages.map((page, idx) => (
|
||||||
|
<li key={page.name} className="flex">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 44"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4 shrink-0 text-zinc-500 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
|
||||||
|
</svg>
|
||||||
|
{page.current ? (
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
className="ml-2 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
{page.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={page.href}
|
||||||
|
className="ml-2 text-xs font-medium text-zinc-500 hover:text-primary"
|
||||||
|
>
|
||||||
|
{page.name}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Restriction Alert */}
|
{/* Restriction Alert */}
|
||||||
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
|
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
|
||||||
@@ -213,7 +254,7 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
{/* Price Range */}
|
{/* Price Range */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-3xl font-bold text-blue-600">
|
<div className="text-3xl font-bold text-primary">
|
||||||
${lowestPrice.toFixed(2)}
|
${lowestPrice.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
{lowestPrice !== highestPrice && (
|
{lowestPrice !== highestPrice && (
|
||||||
@@ -235,13 +276,16 @@ export default function ProductDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add to Build Button */}
|
{/* Add to Build Button */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4 items-center">
|
||||||
<button className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}>
|
<button className="bg-primary hover:bg-primary/80 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}>
|
||||||
Add to Current Build
|
Add to Current Build
|
||||||
</button>
|
</button>
|
||||||
<button className="border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium py-2 px-4 rounded-md transition-colors">
|
<span className="flex items-center text-xs text-zinc-500 hover:text-primary cursor-pointer select-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-1">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75v-1.5A2.25 2.25 0 0015 3h-6a2.25 2.25 0 00-2.25 2.25v15l6-3 6 3v-7.5" />
|
||||||
|
</svg>
|
||||||
Save for Later
|
Save for Later
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{addSuccess && (
|
{addSuccess && (
|
||||||
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
|
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
|
||||||
@@ -278,7 +322,7 @@ export default function ProductDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
<div className="text-2xl font-bold text-primary">
|
||||||
${offer.price.toFixed(2)}
|
${offer.price.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
{offer.inStock !== undefined && (
|
{offer.inStock !== undefined && (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
HomeIcon,
|
HomeIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
|
CubeIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const navigation = [
|
|||||||
{ name: 'Dashboard', href: '/admin', icon: HomeIcon },
|
{ name: 'Dashboard', href: '/admin', icon: HomeIcon },
|
||||||
{ name: 'Users', href: '/admin/users', icon: UsersIcon },
|
{ name: 'Users', href: '/admin/users', icon: UsersIcon },
|
||||||
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon },
|
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon },
|
||||||
|
{ name: 'Products', href: '/admin/products', icon: CubeIcon },
|
||||||
// { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
|
// { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
|
||||||
];
|
];
|
||||||
const userNavigation = [
|
const userNavigation = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
|
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
|
||||||
|
import CategoryTreeTest from '@/components/CategoryTreeTest';
|
||||||
|
|
||||||
type Mapping = {
|
type Mapping = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -161,6 +162,7 @@ export default function CategoryMappingAdmin() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<CategoryTreeTest />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export default function AdminDashboard() {
|
|||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="p-2 bg-blue-100 rounded-lg">
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
<Users className="w-6 h-6 text-blue-600" />
|
<Users className="w-6 h-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-600">Total Users</p>
|
<p className="text-sm font-medium text-gray-600">Total Users</p>
|
||||||
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center text-sm">
|
<div className="mt-4 flex items-center text-sm">
|
||||||
<Activity className="w-4 h-4 text-blue-500 mr-1" />
|
<Activity className="w-4 h-4 text-blue-500 mr-1" />
|
||||||
<span className="text-blue-600">{stats.activeUsers}</span>
|
<span className="text-primary">{stats.activeUsers}</span>
|
||||||
<span className="text-gray-500 ml-1">active users</span>
|
<span className="text-gray-500 ml-1">active users</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
src/app/admin/products/page.tsx
Normal file
160
src/app/admin/products/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
// Core columns for performance and usability
|
||||||
|
const columns = [
|
||||||
|
'uuid',
|
||||||
|
'sku',
|
||||||
|
'brandName',
|
||||||
|
'productName',
|
||||||
|
'department',
|
||||||
|
'category',
|
||||||
|
'subcategory',
|
||||||
|
'retailPrice',
|
||||||
|
'salePrice',
|
||||||
|
'imageUrl',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminProductsPage() {
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [brand, setBrand] = useState('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [subcategory, setSubcategory] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`/api/products?page=${page}&limit=${limit}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setProducts(data.data || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError(err.message || 'Error fetching products');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [page, limit]);
|
||||||
|
|
||||||
|
// Get unique filter options from current page's products
|
||||||
|
const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
|
||||||
|
const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
|
||||||
|
const categoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.category).filter(Boolean))).sort(), [products]);
|
||||||
|
const subcategoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.subcategory).filter(Boolean))).sort(), [products]);
|
||||||
|
|
||||||
|
// Filter products before rendering
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
return products.filter(p =>
|
||||||
|
(!brand || p.brandName === brand) &&
|
||||||
|
(!department || p.department === department) &&
|
||||||
|
(!category || p.category === category) &&
|
||||||
|
(!subcategory || p.subcategory === subcategory)
|
||||||
|
);
|
||||||
|
}, [products, brand, department, category, subcategory]);
|
||||||
|
|
||||||
|
// Reset to page 1 when filters change
|
||||||
|
useEffect(() => { setPage(1); }, [brand, department, category, subcategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Admin Products</h1>
|
||||||
|
{error && <div className="text-red-600 mb-4">{error}</div>}
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 mb-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px]" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Department</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px]" value={department} onChange={e => setDepartment(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Category</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px]" value={category} onChange={e => setCategory(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{categoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||||
|
<select className="border rounded px-2 py-1 min-w-[120px]" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto border rounded bg-white">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||||
|
) : filteredProducts.length === 0 ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||||
|
) : (
|
||||||
|
filteredProducts.map((product, i) => (
|
||||||
|
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||||
|
{col === 'imageUrl' && product[col] ? (
|
||||||
|
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
|
||||||
|
) : (
|
||||||
|
product[col] ?? ''
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>Prev</button>
|
||||||
|
<span>Page {page} of {Math.ceil(total / limit) || 1}</span>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page * limit >= total}
|
||||||
|
>Next</button>
|
||||||
|
<select
|
||||||
|
className="ml-4 border rounded px-2 py-1"
|
||||||
|
value={limit}
|
||||||
|
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||||
|
>
|
||||||
|
{[25, 50, 100, 200].map(opt => (
|
||||||
|
<option key={opt} value={opt}>{opt} / page</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-2 text-zinc-500">Total: {total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export default async function AdminUsersPage() {
|
|||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
<div className="text-2xl font-bold text-blue-600">{usersList.length}</div>
|
<div className="text-2xl font-bold text-primary">{usersList.length}</div>
|
||||||
<div className="text-sm text-gray-500">Total Users</div>
|
<div className="text-sm text-gray-500">Total Users</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
|||||||
@@ -4,5 +4,23 @@ import { product_categories } from '@/db/schema';
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const allCategories = await db.select().from(product_categories);
|
const allCategories = await db.select().from(product_categories);
|
||||||
return NextResponse.json({ success: true, data: allCategories });
|
|
||||||
|
// Build a map of id -> category object (with children array)
|
||||||
|
const categoryMap = Object.fromEntries(
|
||||||
|
allCategories.map(cat => [cat.id, { ...cat, children: [] as any[] }])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the hierarchy
|
||||||
|
const rootCategories: any[] = [];
|
||||||
|
for (const cat of allCategories) {
|
||||||
|
if (cat.parent_category_id) {
|
||||||
|
if (categoryMap[cat.parent_category_id]) {
|
||||||
|
categoryMap[cat.parent_category_id].children.push(categoryMap[cat.id]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rootCategories.push(categoryMap[cat.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: rootCategories });
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,34 @@
|
|||||||
|
import { db } from '@/db';
|
||||||
|
import { bb_products } from '@/db/schema';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET() {
|
function slugify(name: string): string {
|
||||||
const res = await fetch('http://localhost:8080/api/products'); // <-- your Spring backend endpoint
|
return name
|
||||||
const data = await res.json();
|
.toLowerCase()
|
||||||
return NextResponse.json(data);
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get total count using raw SQL
|
||||||
|
const totalResult = await db.execute(sql`SELECT COUNT(*)::int AS count FROM bb_products`);
|
||||||
|
const total = Number(totalResult.rows?.[0]?.count || 0);
|
||||||
|
|
||||||
|
// Get paginated products
|
||||||
|
const allProducts = await db.select().from(bb_products).limit(limit).offset(offset);
|
||||||
|
const mapped = allProducts.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
slug: slugify(item.productName || item.product_name || item.name || ''),
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ success: true, data: mapped, total });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning data-theme="pew">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.className} antialiased`}>
|
<body className={`${inter.className} antialiased`}>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
40
src/components/CategoryTreeTest.tsx
Normal file
40
src/components/CategoryTreeTest.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function CategoryTree({ categories }: { categories: any[] }) {
|
||||||
|
if (!categories || categories.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul style={{ marginLeft: 16 }}>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<li key={cat.id}>
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
{cat.children && cat.children.length > 0 && (
|
||||||
|
<CategoryTree categories={cat.children} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryTreeTest() {
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/product-categories")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setCategories(data.data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Loading categories...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold mb-2">Product Category Hierarchy</h2>
|
||||||
|
<CategoryTree categories={categories} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ export default function Navbar() {
|
|||||||
<>
|
<>
|
||||||
{/* Admin Banner - Moved to top */}
|
{/* Admin Banner - Moved to top */}
|
||||||
{session?.user && (session.user as any)?.isAdmin && (
|
{session?.user && (session.user as any)?.isAdmin && (
|
||||||
<div className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-2 px-4 sm:px-8 relative z-30">
|
<div className="w-full bg-gradient-to-r from-gray-600 to-gray-600 text-white py-2 px-4 sm:px-8 relative z-30">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<ShieldCheckIcon className="h-4 w-4" />
|
<ShieldCheckIcon className="h-4 w-4" />
|
||||||
@@ -92,13 +92,13 @@ export default function Navbar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top Bar */}
|
{/* Top Bar */}
|
||||||
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
<div className="w-full bg-[#4B6516] h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
||||||
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
|
<Link href="/" className="font-bold text-lg tracking-tight text-white hover:underline focus:underline">Pew Builder</Link>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{loading ? null : session?.user ? (
|
{loading ? null : session?.user ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold"
|
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold text-white"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
onClick={() => setMenuOpen((v) => !v)}
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
@@ -177,21 +177,14 @@ export default function Navbar() {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
|
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||||
pathname === item.href
|
pathname === item.href
|
||||||
? 'text-blue-600 font-semibold underline underline-offset-4'
|
? 'text-primary font-semibold underline underline-offset-4'
|
||||||
: 'text-neutral-700 dark:text-neutral-200 hover:text-blue-600'
|
: 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Search */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
|
|
||||||
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</span>
|
||||||
<span className="text-lg font-bold text-blue-600 dark:text-blue-400">${lowestPrice.toFixed(2)}</span>
|
<span className="text-lg font-bold text-primary dark:text-blue-400">${lowestPrice.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
theme: { extend: {} },
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "oklch(45% 0.124 130.933)",
|
||||||
|
secondary: "oklch(37% 0.034 259.733)",
|
||||||
|
accent: {
|
||||||
|
100: "oklch(95% 0.013 255.508)",
|
||||||
|
500: "oklch(70% 0.013 255.508)",
|
||||||
|
600: "oklch(60% 0.013 255.508)",
|
||||||
|
700: "oklch(50% 0.013 255.508)",
|
||||||
|
},
|
||||||
|
neutral: "oklch(14% 0.005 285.823)",
|
||||||
|
base: {
|
||||||
|
100: "oklch(100% 0 0)",
|
||||||
|
200: "oklch(98% 0 0)",
|
||||||
|
300: "oklch(95% 0 0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user