6 Commits

Author SHA1 Message Date
sean b478d9797d /admin and with working with db. data is pulling from db 2025-06-30 20:26:00 -04:00
sean 5c046874a8 most working data fetch. 2025-06-30 13:34:27 -04:00
sean c3151f380b fucking pos. data is working but tailwind is fucked 2025-06-30 13:02:19 -04:00
sean 4798c1139e Merge pull request 'user-auth' (#2) from user-auth into dev
Reviewed-on: #2
2025-06-30 06:37:16 -04:00
sean 41e55404bf nextauth working. 2025-06-30 06:36:03 -04:00
sean ccc6e41724 add state management and other stuff 2025-06-29 15:58:03 -04:00
57 changed files with 6368 additions and 3137 deletions
+10
View File
@@ -0,0 +1,10 @@
const bcrypt = require('bcryptjs');
// Replace with your plaintext password and hash
const plaintextPassword = 'newpassword'; // <-- put your real password here
const hash = '$2b$10$n78/VuxwnDoOemWoqjVKnunz5PZy7SisG3VUhsPtQXKEEnMej6TWK';
bcrypt.compare(plaintextPassword, hash, (err, result) => {
if (err) throw err;
console.log('Password matches hash?', result); // true if matches, false if not
});
+10
View File
@@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
+12
View File
@@ -0,0 +1,12 @@
const bcrypt = require('bcryptjs');
const password = process.argv[2];
if (!password) {
console.error('Usage: node hash-password.js <password>');
process.exit(1);
}
bcrypt.hash(password, 10, (err, hash) => {
if (err) throw err;
console.log('Hashed password:', hash);
});
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
if (!token || !token.isAdmin) {
return NextResponse.redirect(new URL('/account/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["www.brownells.com"],
},
};
module.exports = nextConfig;
-16
View File
@@ -1,16 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'placehold.co',
port: '',
pathname: '/**',
},
],
},
};
export default nextConfig;
+1749 -1291
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -9,23 +9,33 @@
"lint": "next lint"
},
"dependencies": {
"@auth/core": "^0.34.2",
"@auth/drizzle-adapter": "^1.10.0",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"daisyui": "^4.7.3",
"next": "15.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"date-fns": "^4.1.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2",
"lucide-react": "^0.525.0",
"next": "^14.2.30",
"next-auth": "^4.24.11",
"pg": "^8.16.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "^3.4.3",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"eslint": "^9",
"eslint-config-next": "15.3.4",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.4",
"typescript": "^5"
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
};
}
@@ -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>
);
}
+43
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>
);
}
+168
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>
);
}
+34
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>
);
}
+94
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>
);
}
@@ -4,6 +4,10 @@ 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 = [
@@ -215,12 +219,60 @@ 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) {
@@ -278,36 +330,66 @@ export default function BuildPage() {
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
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 => component.status === 'completed').length;
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">AR-15 Build Checklist</h1>
<p className="text-gray-600 mt-2">Track your build progress and find required components</p>
<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">
<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>
@@ -320,26 +402,138 @@ export default function BuildPage() {
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
<div className="text-sm text-gray-500">Remaining</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">${totalEstimatedCost}</div>
<div className="text-sm text-gray-500">Estimated Cost</div>
<div 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-4">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 !bg-white">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Category Dropdown */}
<div>
<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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900"
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}>
@@ -350,9 +544,9 @@ export default function BuildPage() {
</div>
{/* Status Filter */}
<div>
<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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900">
<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>
@@ -361,12 +555,12 @@ export default function BuildPage() {
</div>
{/* Sort by */}
<div>
<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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900"
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>
@@ -376,8 +570,8 @@ export default function BuildPage() {
</div>
{/* Clear Filters */}
<div className="flex items-end">
<button className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<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>
@@ -387,10 +581,10 @@ export default function BuildPage() {
{/* Build Components Table */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<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">
<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
@@ -413,15 +607,12 @@ export default function BuildPage() {
<span className="text-sm">{getSortIcon('category')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('estimatedPrice')}
>
<div className="flex items-center space-x-1">
<span>Est. Price</span>
<span>Price</span>
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
</div>
</th>
@@ -429,7 +620,7 @@ export default function BuildPage() {
Notes
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
Selected Product
</th>
</tr>
</thead>
@@ -446,78 +637,103 @@ export default function BuildPage() {
return (
<React.Fragment key={group.name}>
{/* Group Header */}
<tr className="bg-gray-800">
<td colSpan={7} className="px-6 py-4">
<tr className="bg-gray-100">
<td colSpan={7} className="px-6 py-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<span className="text-white font-semibold text-lg">
{group.name === 'Upper Parts' ? '🔫' :
group.name === 'Lower Parts' ? '🔧' : '📦'}
</span>
</div>
</div>
<div className="ml-4">
<h3 className="text-xl font-bold text-white">{group.name}</h3>
<p className="text-gray-300">{group.description}</p>
<div>
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
</div>
<div className="ml-auto text-right">
<div className="text-gray-300 font-medium">
<div className="text-xs text-gray-500 font-medium">
{groupComponents.length} components
</div>
</div>
</div>
</td>
</tr>
{/* Group Components */}
{groupComponents.map((component) => (
<tr key={component.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
{component.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-xs text-gray-500">
{component.required ? 'Required' : 'Optional'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{component.category}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.description}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
${component.estimatedPrice}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.notes}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<Link
href={`/parts?category=${encodeURIComponent(component.category)}`}
className="bg-[#4B6516] text-white py-1 px-3 rounded text-xs hover:bg-[#3a4e12] transition-colors"
>
Find Parts
</Link>
</div>
</td>
</tr>
))}
{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>
);
})
@@ -547,7 +763,7 @@ export default function BuildPage() {
)}
</div>
<div className="text-sm text-gray-500">
Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)}
Total Value: ${actualTotalCost.toFixed(2)}
</div>
</div>
</div>
+20
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>
);
}
+6 -6
View File
@@ -1,4 +1,4 @@
import BetaTester from "../components/BetaTester";
import BetaTester from "@/components/BetaTester";
import Link from 'next/link';
export default function LandingPage() {
@@ -29,12 +29,11 @@ export default function LandingPage() {
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">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
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="/Builder"
href="/build"
className="btn btn-primary text-base font-semibold px-6"
>
Get Building
@@ -42,11 +41,12 @@ export default function LandingPage() {
</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">
<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 shadow-lg"
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>
+124
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
};
+847
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>
);
}
@@ -1,19 +1,76 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { mockProducts } from '@/mock/product';
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 productId = params.id as string;
const product = mockProducts.find(p => p.id === productId);
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 (
@@ -25,15 +82,46 @@ export default function ProductDetailsPage() {
);
}
// Use images array if present, otherwise fallback to image_url
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 averageRating = product.reviews
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
: 0;
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">
@@ -41,8 +129,8 @@ export default function ProductDetailsPage() {
<div className="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href={`/products?category=${product.category.id}`}>{product.category.name}</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>
@@ -60,7 +148,7 @@ export default function ProductDetailsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Product Images */}
<div className="space-y-4">
<div className="aspect-square bg-neutral-100 dark:bg-neutral-800 rounded-lg overflow-hidden">
<div className="aspect-square bg-zinc-100 dark:bg-zinc-800 rounded-lg overflow-hidden">
<Image
src={allImages[selectedImageIndex]}
alt={product.name}
@@ -79,7 +167,7 @@ export default function ProductDetailsPage() {
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${
selectedImageIndex === index
? 'border-primary-500'
: 'border-neutral-200 dark:border-neutral-700'
: 'border-zinc-200 dark:border-zinc-700'
}`}
>
<Image
@@ -109,20 +197,17 @@ export default function ProductDetailsPage() {
/>
)}
<div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.brand.name}
</div>
<div className="flex items-center gap-2">
<span className="text-2xl">{product.category.icon}</span>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{product.category.name}
</span>
<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-neutral-900 dark:text-white">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">
{product.name}
</h1>
@@ -132,104 +217,38 @@ export default function ProductDetailsPage() {
${lowestPrice.toFixed(2)}
</div>
{lowestPrice !== highestPrice && (
<div className="text-lg text-neutral-600 dark:text-neutral-400">
<div className="text-lg text-zinc-600 dark:text-zinc-400">
- ${highestPrice.toFixed(2)}
</div>
)}
<div className="text-sm text-neutral-500">
<div className="text-sm text-zinc-500">
from {product.offers.length} vendor{product.offers.length > 1 ? 's' : ''}
</div>
</div>
{/* Reviews */}
{product.reviews && product.reviews.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={`h-5 w-5 ${
star <= averageRating
? 'text-yellow-400'
: 'text-neutral-300 dark:text-neutral-600'
}`}
/>
))}
</div>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{averageRating.toFixed(1)} ({product.reviews.length} reviews)
</span>
</div>
)}
{/* Description */}
<div>
<h3 className="text-lg font-semibold mb-2">Description</h3>
<p className="text-neutral-700 dark:text-neutral-300">
<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">
<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>
{/* Specifications */}
{product.specifications && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Specifications</h2>
<div className="card">
<div className="card-body">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{product.specifications.weight && (
<div>
<span className="font-semibold">Weight:</span> {product.specifications.weight}
</div>
)}
{product.specifications.length && (
<div>
<span className="font-semibold">Length:</span> {product.specifications.length}
</div>
)}
{product.specifications.material && (
<div>
<span className="font-semibold">Material:</span> {product.specifications.material}
</div>
)}
{product.specifications.finish && (
<div>
<span className="font-semibold">Finish:</span> {product.specifications.finish}
</div>
)}
{product.specifications.caliber && (
<div>
<span className="font-semibold">Caliber:</span> {product.specifications.caliber}
</div>
)}
{product.specifications.compatibility && (
<div className="md:col-span-2">
<span className="font-semibold">Compatibility:</span>
<div className="flex flex-wrap gap-2 mt-1">
{product.specifications.compatibility.map((comp, index) => (
<span key={index} className="badge badge-outline">{comp}</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Vendor Offers */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Where to Buy</h2>
@@ -251,7 +270,7 @@ export default function ProductDetailsPage() {
<div>
<div className="font-semibold">{offer.vendor.name}</div>
{offer.shipping && (
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{offer.shipping}
</div>
)}
@@ -283,52 +302,6 @@ export default function ProductDetailsPage() {
))}
</div>
</div>
{/* Reviews */}
{product.reviews && product.reviews.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
<div className="space-y-4">
{product.reviews.map((review) => (
<div key={review.id} className="card">
<div className="card-body">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={`h-4 w-4 ${
star <= review.rating
? 'text-yellow-400'
: 'text-neutral-300 dark:text-neutral-600'
}`}
/>
))}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{new Date(review.date).toLocaleDateString()}
</div>
</div>
<div className="font-semibold mb-1">{review.user}</div>
<p className="text-neutral-700 dark:text-neutral-300">{review.comment}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Compatibility */}
{product.compatibility && product.compatibility.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Compatible Parts</h2>
<div className="flex flex-wrap gap-2">
{product.compatibility.map((part, index) => (
<span key={index} className="badge badge-primary">{part}</span>
))}
</div>
</div>
)}
</div>
);
}
+28
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>
);
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import { useState, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import {
Dialog,
DialogBackdrop,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
} from '@headlessui/react';
import {
Bars3Icon,
BellIcon,
CalendarIcon,
ChartPieIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
FolderIcon,
HomeIcon,
UsersIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
const navigation = [
{ name: 'Dashboard', href: '/admin', icon: HomeIcon },
{ name: 'Users', href: '/admin/users', icon: UsersIcon },
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon },
// { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
];
const userNavigation = [
{ name: 'Your profile', href: '#' },
{ name: 'Sign out', href: '#' },
];
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export default function AdminNavbar({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
return (
<>
<div>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
{/* Sidebar component */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
pathname === item.href
? 'bg-gray-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
pathname === item.href ? 'text-indigo-600' : 'text-gray-400 group-hover:text-indigo-600',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
{/* Sidebar component */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
pathname === item.href
? 'bg-gray-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
pathname === item.href ? 'text-indigo-600' : 'text-gray-400 group-hover:text-indigo-600',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</div>
<div className="lg:pl-72">
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button type="button" onClick={() => setSidebarOpen(true)} className="-m-2.5 p-2.5 text-gray-700 lg:hidden">
<span className="sr-only">Open sidebar</span>
<Bars3Icon aria-hidden="true" className="size-6" />
</button>
{/* Separator */}
<div aria-hidden="true" className="h-6 w-px bg-gray-200 lg:hidden" />
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
<input
name="search"
type="search"
placeholder="Search"
aria-label="Search"
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-none placeholder:text-gray-400 sm:text-sm/6"
/>
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
/>
</form>
<div className="flex items-center gap-x-4 lg:gap-x-6">
<button type="button" className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500">
<span className="sr-only">View notifications</span>
<BellIcon aria-hidden="true" className="size-6" />
</button>
{/* Separator */}
<div aria-hidden="true" className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200" />
{/* Profile dropdown */}
<Menu as="div" className="relative">
<MenuButton className="relative flex items-center">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-50"
/>
<span className="hidden lg:flex lg:items-center">
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900">
Tom Cook
</span>
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400" />
</span>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 transition focus:outline-none data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
{userNavigation.map((item) => (
<MenuItem key={item.name}>
<a
href={item.href}
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none"
>
{item.name}
</a>
</MenuItem>
))}
</MenuItems>
</Menu>
</div>
</div>
</div>
<main className="py-10">
{children}
</main>
</div>
</div>
</>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
type Mapping = {
id: number;
feedname: string;
affiliatecategory: string;
buildercategoryid: number;
notes?: string;
};
type MappingForm = {
feedname: string;
affiliatecategory: string;
buildercategoryid: string;
notes?: string;
};
export default function CategoryMappingAdmin() {
const [mappings, setMappings] = useState<Mapping[]>([]);
const [form, setForm] = useState<MappingForm>({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<MappingForm>({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
const [productCategories, setProductCategories] = useState<any[]>([]);
// Fetch all mappings
const fetchMappings = async () => {
const res = await fetch("/api/category-mapping");
const data = await res.json();
setMappings(data.data || []);
};
const fetchProductCategories = async () => {
const res = await fetch("/api/product-categories");
const data = await res.json();
setProductCategories(data.data || []);
};
useEffect(() => {
fetchMappings();
fetchProductCategories();
}, []);
// Add new mapping
const handleAdd = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
await fetch("/api/category-mapping", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
buildercategoryid: parseInt(form.buildercategoryid, 10)
}),
});
setForm({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
fetchMappings();
};
// Edit mapping
const handleEdit = (mapping: Mapping) => {
setEditingId(mapping.id);
setEditForm({ ...mapping, buildercategoryid: mapping.buildercategoryid.toString() });
};
const handleUpdate = async (e: FormEvent<HTMLButtonElement>) => {
e.preventDefault();
await fetch("/api/category-mapping", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...editForm, buildercategoryid: parseInt(editForm.buildercategoryid, 10), id: editingId }),
});
setEditingId(null);
fetchMappings();
};
// Delete mapping
const handleDelete = async (id: number) => {
await fetch("/api/category-mapping", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
fetchMappings();
};
return (
<div className="max-w-3xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-4">Affiliate Category Mapping Admin</h1>
<div className="mb-8">
<h2 className="text-xl font-semibold mb-2">AR15 Category Hierarchy</h2>
<table className="min-w-full border text-sm">
<thead>
<tr className="bg-zinc-100">
<th className="border px-2 py-1">ID</th>
<th className="border px-2 py-1">Category</th>
<th className="border px-2 py-1">Parent</th>
<th className="border px-2 py-1">Type</th>
</tr>
</thead>
<tbody>
{productCategories
.filter(cat => cat.type === 'AR15')
.map(cat => {
const parent = productCategories.find(p => p.id === cat.parent_category_id);
return (
<tr key={cat.id}>
<td className="border px-2 py-1">{cat.id}</td>
<td className="border px-2 py-1">{cat.name}</td>
<td className="border px-2 py-1">{parent ? parent.name : 'Top Level'}</td>
<td className="border px-2 py-1">{cat.type}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<form onSubmit={handleAdd} className="flex gap-2 mb-6">
<input className="border px-2 py-1 rounded w-32" placeholder="Feed Name" value={form.feedname} onChange={e => setForm(f => ({ ...f, feedname: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-48" placeholder="Affiliate Category" value={form.affiliatecategory} onChange={e => setForm(f => ({ ...f, affiliatecategory: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-40" placeholder="Builder Category ID" value={form.buildercategoryid} onChange={e => setForm(f => ({ ...f, buildercategoryid: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-32" placeholder="Notes" value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
<button className="bg-blue-600 text-white px-3 py-1 rounded" type="submit">Add</button>
</form>
<table className="min-w-full border text-sm">
<thead>
<tr className="bg-zinc-100">
<th className="border px-2 py-1">Feed Name</th>
<th className="border px-2 py-1">Affiliate Category</th>
<th className="border px-2 py-1">Builder Category ID</th>
<th className="border px-2 py-1">Notes</th>
<th className="border px-2 py-1">Actions</th>
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr key={m.id}>
{editingId === m.id ? (
<>
<td className="border px-2 py-1"><input className="border px-1 rounded w-28" value={editForm.feedname} onChange={e => setEditForm(f => ({ ...f, feedname: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-40" value={editForm.affiliatecategory} onChange={e => setEditForm(f => ({ ...f, affiliatecategory: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-32" value={editForm.buildercategoryid} onChange={e => setEditForm(f => ({ ...f, buildercategoryid: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-24" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} /></td>
<td className="border px-2 py-1">
<button className="bg-green-600 text-white px-2 py-1 rounded mr-1" onClick={handleUpdate}>Save</button>
<button className="bg-gray-400 text-white px-2 py-1 rounded" onClick={() => setEditingId(null)}>Cancel</button>
</td>
</>
) : (
<>
<td className="border px-2 py-1">{m.feedname}</td>
<td className="border px-2 py-1">{m.affiliatecategory}</td>
<td className="border px-2 py-1">{m.buildercategoryid}</td>
<td className="border px-2 py-1">{m.notes}</td>
<td className="border px-2 py-1">
<button className="bg-yellow-500 text-white px-2 py-1 rounded mr-1" onClick={() => handleEdit(m)}>Edit</button>
<button className="bg-red-600 text-white px-2 py-1 rounded" onClick={() => handleDelete(m.id)}>Delete</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import AdminNavbar from './AdminNavbar';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminNavbar>
{children}
</AdminNavbar>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState } from 'react';
import {
Users,
ShoppingCart,
TrendingUp,
AlertTriangle,
CheckCircle,
Clock,
BarChart3,
Activity,
Calendar,
Settings
} from 'lucide-react';
export default function AdminDashboard() {
const [selectedPeriod, setSelectedPeriod] = useState('7d');
// Sample data - in a real app, this would come from your database
const stats = {
totalUsers: 1247,
activeUsers: 892,
totalProducts: 15420,
totalBuilds: 3421,
revenue: 45678,
growth: 12.5,
pendingApprovals: 23,
systemAlerts: 2
};
const recentActivity = [
{ id: 1, user: 'John Doe', action: 'Created new build', time: '2 minutes ago', type: 'build' },
{ id: 2, user: 'Jane Smith', action: 'Updated profile', time: '5 minutes ago', type: 'profile' },
{ id: 3, user: 'Mike Johnson', action: 'Added product to cart', time: '8 minutes ago', type: 'cart' },
{ id: 4, user: 'Sarah Wilson', action: 'Completed purchase', time: '12 minutes ago', type: 'purchase' },
{ id: 5, user: 'Admin User', action: 'Updated category mapping', time: '15 minutes ago', type: 'admin' }
];
const topProducts = [
{ name: 'AR-15 Lower Receiver', sales: 156, revenue: 12480 },
{ name: 'Tactical Scope', sales: 89, revenue: 17800 },
{ name: 'Gun Case', sales: 234, revenue: 11700 },
{ name: 'Ammo Storage', sales: 67, revenue: 3350 },
{ name: 'Cleaning Kit', sales: 189, revenue: 5670 }
];
const systemStatus = [
{ service: 'Database', status: 'healthy', uptime: '99.9%' },
{ service: 'API', status: 'healthy', uptime: '99.8%' },
{ service: 'File Storage', status: 'warning', uptime: '98.5%' },
{ service: 'Email Service', status: 'healthy', uptime: '99.7%' }
];
return (
<div className="p-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p className="text-gray-600 mt-1">Welcome back! Here's what's happening with your platform.</p>
</div>
<div className="flex items-center space-x-4">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors">
<Settings className="w-4 h-4 inline mr-2" />
Settings
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="w-6 h-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Users</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">+{stats.growth}%</span>
<span className="text-gray-500 ml-1">from last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<ShoppingCart className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Products</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalProducts.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">Active</span>
<span className="text-gray-500 ml-1">in catalog</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-purple-100 rounded-lg">
<BarChart3 className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Builds</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalBuilds.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<Activity className="w-4 h-4 text-blue-500 mr-1" />
<span className="text-blue-600">{stats.activeUsers}</span>
<span className="text-gray-500 ml-1">active users</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-orange-100 rounded-lg">
<TrendingUp className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Revenue</p>
<p className="text-2xl font-bold text-gray-900">${stats.revenue.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">+8.2%</span>
<span className="text-gray-500 ml-1">from last month</span>
</div>
</div>
</div>
{/* Alerts and Notifications */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
</div>
<div className="p-6">
<div className="space-y-4">
{recentActivity.map((activity) => (
<div key={activity.id} className="flex items-center space-x-3">
<div className={`w-2 h-2 rounded-full ${
activity.type === 'build' ? 'bg-blue-500' :
activity.type === 'profile' ? 'bg-green-500' :
activity.type === 'cart' ? 'bg-orange-500' :
activity.type === 'purchase' ? 'bg-purple-500' :
'bg-gray-500'
}`} />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{activity.user}</p>
<p className="text-sm text-gray-500">{activity.action}</p>
</div>
<div className="text-sm text-gray-400">{activity.time}</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* Pending Actions */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Pending Actions</h3>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-3" />
<span className="text-sm font-medium">Approvals Needed</span>
</div>
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
{stats.pendingApprovals}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 text-red-500 mr-3" />
<span className="text-sm font-medium">System Alerts</span>
</div>
<span className="bg-red-100 text-red-800 text-xs font-medium px-2 py-1 rounded-full">
{stats.systemAlerts}
</span>
</div>
</div>
</div>
</div>
{/* System Status */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">System Status</h3>
</div>
<div className="p-6">
<div className="space-y-3">
{systemStatus.map((service, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{service.service}</span>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
service.status === 'healthy' ? 'bg-green-500' : 'bg-yellow-500'
}`} />
<span className="text-xs text-gray-500">{service.uptime}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Top Products */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Top Products</h3>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sales
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{topProducts.map((product, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{product.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.sales}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${product.revenue.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import React from 'react';
import { db } from '@/db';
import { users } from '@/db/schema';
import { format } from 'date-fns';
async function getUsers() {
try {
const allUsers = await db.query.users.findMany({
columns: {
id: true,
email: true,
name: true,
first_name: true,
last_name: true,
isAdmin: true,
emailVerified: true,
createdAt: true,
lastLogin: true,
buildPrivacySetting: true
},
orderBy: (users, { desc }) => [desc(users.createdAt)]
});
return allUsers;
} catch (error) {
console.error('Error fetching users:', error);
return [];
}
}
export default async function AdminUsersPage() {
const usersList = await getUsers();
const adminCount = usersList.filter(user => user.isAdmin).length;
const verifiedCount = usersList.filter(user => user.emailVerified).length;
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Users</h1>
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-500">
Total: {usersList.length} | Admins: {adminCount} | Verified: {verifiedCount}
</div>
</div>
</div>
{/* Stats Cards */}
<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="text-2xl font-bold text-blue-600">{usersList.length}</div>
<div className="text-sm text-gray-500">Total Users</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-purple-600">{adminCount}</div>
<div className="text-sm text-gray-500">Admins</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{verifiedCount}</div>
<div className="text-sm text-gray-500">Verified</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-orange-600">{usersList.length - verifiedCount}</div>
<div className="text-sm text-gray-500">Unverified</div>
</div>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<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">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Privacy
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{usersList.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-sm font-medium text-gray-700">
{user.first_name?.[0] || user.last_name?.[0] || user.email[0].toUpperCase()}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.name || 'No name'
}
</div>
<div className="text-sm text-gray-500">
ID: {user.id}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.emailVerified
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{user.emailVerified ? 'Verified' : 'Unverified'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.isAdmin
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{user.isAdmin ? 'Admin' : 'User'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.createdAt ? format(new Date(user.createdAt), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.lastLogin ? format(new Date(user.lastLogin), 'MMM d, yyyy') : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.buildPrivacySetting === 'public'
? 'bg-blue-100 text-blue-800'
: 'bg-orange-100 text-orange-800'
}`}>
{user.buildPrivacySetting || 'public'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{usersList.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500">No users found</div>
</div>
)}
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';
import { users } from '@/db/schema';
import bcrypt from 'bcryptjs';
const handler = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: 'jwt' },
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
console.log('Credentials received:', credentials);
if (!credentials?.email || !credentials?.password) return null;
// Query the real users table using Drizzle
const foundUser = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, credentials.email),
});
console.log('User found:', foundUser);
if (foundUser && foundUser.hashedPassword && await bcrypt.compare(credentials.password, foundUser.hashedPassword)) {
console.log('Returning user:', foundUser);
return foundUser;
}
return null;
}
}),
],
pages: {
signIn: '/account/login',
// signUp: '/account/register', // Uncomment when register page is ready
// error: '/account/error', // Uncomment when error page is ready
},
callbacks: {
async session({ session, user, token }) {
console.log('Session callback - user:', user);
console.log('Session callback - token:', token);
if (session.user) {
(session.user as any).isAdmin = (user as any)?.isAdmin ?? token?.isAdmin ?? false;
console.log('Session callback - final isAdmin:', (session.user as any).isAdmin);
}
return session;
},
async jwt({ token, user }) {
console.log('JWT callback - user:', user);
console.log('JWT callback - token before:', token);
if (user && typeof user === 'object' && 'isAdmin' in user) {
(token as any).isAdmin = (user as any).isAdmin;
console.log('JWT callback - setting isAdmin to:', (user as any).isAdmin);
}
console.log('JWT callback - token after:', token);
return token;
}
},
})
export { handler as GET, handler as POST }
+17
View File
@@ -0,0 +1,17 @@
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
console.log('Check Admin API - Token:', token);
console.log('Check Admin API - isAdmin:', token?.isAdmin);
return NextResponse.json({
isAdmin: token?.isAdmin || false,
token: token
});
}
+7
View File
@@ -0,0 +1,7 @@
import { db } from "@/db";
import { brands } from "@/db/schema";
export async function GET() {
const allBrands = await db.select().from(brands);
return Response.json({ success: true, data: allBrands });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { categories } from '@/db/schema';
export async function GET() {
const allCategories = await db.select().from(categories);
return NextResponse.json({ success: true, data: allCategories });
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { affiliateCategoryMap } from '@/db/schema';
import { eq } from 'drizzle-orm';
// GET: List all mappings
export async function GET() {
try {
const mappings = await db.select().from(affiliateCategoryMap);
return NextResponse.json({ success: true, data: mappings });
} catch (error: any) {
console.error('GET /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error', data: [] }, { status: 500 });
}
}
// POST: Create a new mapping
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { feedname, affiliatecategory, buildercategoryid, notes } = body;
if (!feedname || !affiliatecategory || !buildercategoryid) {
return NextResponse.json({ success: false, error: 'Missing required fields' }, { status: 400 });
}
const [inserted] = await db.insert(affiliateCategoryMap).values({ feedname, affiliatecategory, buildercategoryid, notes }).returning();
return NextResponse.json({ success: true, data: inserted });
} catch (error: any) {
console.error('POST /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
// PUT: Update a mapping
export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const { id, feedname, affiliatecategory, buildercategoryid, notes } = body;
if (!id) {
return NextResponse.json({ success: false, error: 'Missing id' }, { status: 400 });
}
const [updated] = await db.update(affiliateCategoryMap)
.set({ feedname, affiliatecategory, buildercategoryid, notes })
.where(eq(affiliateCategoryMap.id, id))
.returning();
return NextResponse.json({ success: true, data: updated });
} catch (error: any) {
console.error('PUT /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
// DELETE: Remove a mapping
export async function DELETE(req: NextRequest) {
try {
const body = await req.json();
const { id } = body;
if (!id) {
return NextResponse.json({ success: false, error: 'Missing id' }, { status: 400 });
}
await db.delete(affiliateCategoryMap).where(eq(affiliateCategoryMap.id, id));
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('DELETE /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { product_categories } from '@/db/schema';
export async function GET() {
const allCategories = await db.select().from(product_categories);
return NextResponse.json({ success: true, data: allCategories });
}
+58
View File
@@ -0,0 +1,58 @@
import { db } from '@/db';
import { bb_products } from '@/db/schema';
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
export async function GET(
req: Request,
{ params }: { params: { slug: string } }
) {
try {
const allProducts = await db.select().from(bb_products);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(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',
logo: '',
},
inStock: true,
shipping: '',
},
],
restrictions: {},
}));
const found = mapped.find((p: any) => p.slug === params.slug);
if (found) {
return Response.json({ success: true, product: found });
} else {
return Response.json({ success: false, error: 'Not found' }, { status: 404 });
}
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
}
// Check if user already exists
const existing = users.find((u) => u.email === email);
if (existing) {
return NextResponse.json({ error: 'User already exists.' }, { status: 400 });
}
// Add new user
users.push({ email, password });
return NextResponse.json({ success: true });
}
+24
View File
@@ -0,0 +1,24 @@
import { db } from "@/db";
import { bb_products } from "@/db/schema";
export async function GET() {
try {
const allProducts = await db.select().from(bb_products).limit(50);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(item.productName),
...item
}));
return Response.json({ success: true, data: mapped });
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
+58
View File
@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply transition-colors duration-200;
}
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-100 dark:bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-300 dark:bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-400 dark:bg-zinc-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-zinc-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
+46 -41
View File
@@ -3,51 +3,56 @@
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
html {
scroll-behavior: smooth;
}
body {
@apply transition-colors duration-200;
}
}
body {
@apply transition-colors duration-200;
}
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
::-webkit-scrollbar-track {
@apply bg-zinc-100 dark:bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-300 dark:bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-400 dark:bg-zinc-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-zinc-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
::-webkit-scrollbar-track {
@apply bg-neutral-100 dark:bg-neutral-800;
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-600 rounded-full;
.animate-float {
animation: float 4s ease-in-out infinite;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400 dark:bg-neutral-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
.btn-secondary {
@apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
+1 -8
View File
@@ -1,8 +1,6 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Navbar from "@/components/Navbar";
import { ThemeProvider } from "@/components/ThemeProvider";
const inter = Inter({ subsets: ["latin"] });
@@ -19,12 +17,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning data-theme="pew">
<body className={`${inter.className} antialiased`}>
<ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<Navbar />
{children}
</div>
</ThemeProvider>
{children}
</body>
</html>
);
-622
View File
@@ -1,622 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon } 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 { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
import Image from 'next/image';
// Extract unique values for dropdowns
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
// Restrictions for filter dropdown
const restrictionOptions = [
'',
'NFA',
'SBR',
'SUPPRESSOR',
'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: string[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{value || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-neutral-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-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 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 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
export default function Home() {
const searchParams = useSearchParams();
const [selectedCategory, setSelectedCategory] = 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');
// Read category from URL parameter on page load
useEffect(() => {
const categoryParam = searchParams.get('category');
if (categoryParam && categories.includes(categoryParam)) {
setSelectedCategory(categoryParam);
}
}, [searchParams]);
// Filter parts based on selected criteria
const filteredParts = mockProducts.filter(part => {
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
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
let matchesRestriction = true;
if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
else if (selectedRestriction === 'SUPPRESSOR') matchesRestriction = !!part.restrictions?.suppressor;
else if (selectedRestriction === 'STATE_RESTRICTIONS') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
else 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 && 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 = () => {
setSelectedCategory('All');
setSelectedBrand('All');
setSelectedVendor('All');
setSearchTerm('');
setPriceRange('');
setSelectedRestriction('');
};
const hasActiveFilters = selectedCategory !== '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;
};
return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
{/* Page Title */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
Parts Catalog
{selectedCategory !== 'All' && (
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
- {selectedCategory}
</span>
)}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
{selectedCategory !== 'All'
? `Showing ${selectedCategory} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<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"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search parts..."
/>
</div>
</div>
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Category Dropdown */}
<Dropdown
label="Category"
value={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
{/* Brand Dropdown */}
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
{/* Vendor Dropdown */}
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
{/* Price Range */}
<div className="relative">
<Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{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-5 w-5 text-neutral-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-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 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 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
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 dark:text-primary-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
{/* Clear Filters */}
<div className="flex items-end">
<button
onClick={clearFilters}
disabled={!hasActiveFilters}
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-4 w-4" />
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-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">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>
{/* Restriction Alert Example */}
{sortedParts.some(part => part.restrictions?.nfa) && (
<div className="mb-6">
<RestrictionAlert
type="warning"
title="NFA Items Detected"
message="Some items in your search require National Firearms Act registration. Please ensure compliance with all federal and state regulations."
icon="🔒"
/>
</div>
)}
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
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-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]">
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700">
<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>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{part.name}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</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-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<Link href={`/products/${part.id}`} legacyBehavior>
<a className="btn btn-primary btn-sm">
Add
</a>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
</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} />
))}
</div>
)}
</div>
{/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
<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>
);
}
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useAuthStore } from '@/store/useAuthStore';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const { setSession, setLoading } = useAuthStore();
useEffect(() => {
setSession(session);
setLoading(status === 'loading');
}, [session, status, setSession, setLoading]);
return <>{children}</>;
}
+120 -9
View File
@@ -3,10 +3,23 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import ThemeSwitcher from './ThemeSwitcher';
import { MagnifyingGlassIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useState, ReactNode } from 'react';
interface MenuItem {
label: string;
href?: string;
active?: boolean;
onClick?: () => void;
custom?: ReactNode;
}
export default function Navbar() {
const pathname = usePathname();
const { data: session, status } = useSession();
const loading = status === 'loading';
const [menuOpen, setMenuOpen] = useState(false);
const navItems = [
{ href: '/parts', label: 'Parts Catalog' },
@@ -14,12 +27,114 @@ export default function Navbar() {
{ href: '/my-builds', label: 'My Builds' },
];
// Dropdown menu items
const rawMenuItems = [
session?.user
? {
label: 'Profile',
href: '/account/profile',
active: pathname === '/account/profile',
onClick: () => setMenuOpen(false),
}
: undefined,
session?.user
? {
label: 'Sign Out',
onClick: () => {
setMenuOpen(false);
signOut({ callbackUrl: '/' });
},
}
: {
label: 'Sign In',
onClick: () => {
setMenuOpen(false);
signIn();
},
},
{
label: 'Theme',
custom: <ThemeSwitcher />,
},
];
function isMenuItem(item: unknown): item is MenuItem {
return typeof item === 'object' && item !== null && 'label' in item;
}
const menuItems = rawMenuItems.filter(isMenuItem);
return (
<>
{/* Top Bar */}
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8">
<span className="font-bold text-lg tracking-tight">Pew Builder</span>
<UserCircleIcon className="h-7 w-7 text-white opacity-80" />
<div className="w-full bg-[#4B6516] text-white 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>
<div className="relative">
{loading ? null : session?.user ? (
<>
<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"
aria-haspopup="true"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
My Profile
{session.user.name && (
<span className="hidden sm:inline text-white font-medium text-sm">({session.user.name})</span>
)}
</button>
{menuOpen && (
<div
className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-md shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none divide-y divide-neutral-100 dark:divide-neutral-700 animate-fade-in"
tabIndex={-1}
onBlur={() => setMenuOpen(false)}
>
<div className="py-1">
{session?.user && (
<div className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400 border-b border-neutral-100 dark:border-neutral-700">
{session.user.email}
</div>
)}
{menuItems.map((item, idx) =>
item.custom ? (
<div key={idx} className="px-4 py-2 flex items-center justify-between">
<span className="text-sm text-neutral-700 dark:text-neutral-200">Theme</span>
{item.custom}
</div>
) : item.href ? (
<Link
key={idx}
href={item.href}
className={`block px-4 py-2 text-sm rounded transition-colors ${
item.active
? 'bg-primary/10 text-primary font-semibold'
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
onClick={item.onClick}
>
{item.label}
</Link>
) : (
<button
key={idx}
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded transition-colors"
onClick={item.onClick}
>
{item.label}
</button>
)
)}
</div>
</div>
)}
</>
) : (
<button
className="btn btn-sm btn-primary font-semibold px-4 py-1"
onClick={() => signIn()}
>
Sign In
</button>
)}
</div>
</div>
{/* Subnav */}
@@ -42,15 +157,11 @@ export default function Navbar() {
))}
</div>
{/* Right: Sign In + Search */}
{/* Right: Search */}
<div className="flex items-center space-x-4">
<Link href="/signin" className="btn btn-sm btn-ghost text-sm font-medium">
Sign In
</Link>
<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>
<ThemeSwitcher />
</div>
</div>
</nav>
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { usePathname } from 'next/navigation';
import Navbar from './Navbar';
export default function NavigationWrapper() {
const pathname = usePathname();
const isAccountPage = pathname?.startsWith('/account');
if (isAccountPage) {
return null;
}
return <Navbar />;
}
+20 -2
View File
@@ -6,6 +6,8 @@ import { Product } from '@/mock/product';
interface ProductCardProps {
product: Product;
onAdd?: () => void;
added?: boolean;
}
function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
@@ -17,7 +19,7 @@ function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
return flags;
}
export default function ProductCard({ product }: ProductCardProps) {
export default function ProductCard({ product, onAdd, added }: ProductCardProps) {
const [imageError, setImageError] = useState(false);
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
@@ -86,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
<figure className="relative">
<img
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
src={imageError ? '/window.svg' : product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
onError={() => setImageError(true)}
@@ -116,6 +118,22 @@ export default function ProductCard({ product }: ProductCardProps) {
View Details
</a>
</Link>
{onAdd && (
<button
className="btn btn-neutral btn-sm ml-2 flex items-center gap-1"
onClick={onAdd}
disabled={added}
>
{added ? (
'Added!'
) : (
<>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</>
)}
</button>
)}
</div>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { AuthProvider } from './AuthProvider';
import { ThemeProvider } from './ThemeProvider';
import NavigationWrapper from './NavigationWrapper';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<AuthProvider>
<ThemeProvider>
<div className="min-h-screen bg-zinc-50 transition-colors duration-200">
<NavigationWrapper />
{children}
</div>
</ThemeProvider>
</AuthProvider>
</SessionProvider>
);
}
+3 -5
View File
@@ -15,14 +15,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// Check for saved theme preference or default to light mode
// Only use saved theme preference, otherwise default to light
const savedTheme = localStorage.getItem('theme') as Theme;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else if (prefersDark) {
setTheme('dark');
} else {
setTheme('light');
}
}, []);
+2 -2
View File
@@ -9,8 +9,8 @@ export default function ThemeSwitcher() {
return (
<button
onClick={toggleTheme}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode. Default is light unless changed.`}
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
+6
View File
@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
+571
View File
@@ -0,0 +1,571 @@
import { pgTableCreator, integer, varchar, text, numeric, timestamp, unique, check, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView, index, serial } from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
import { DATABASE_PREFIX as prefix } from "@/lib/constants";
export const pgTable = pgTableCreator((name) => (prefix == "" || prefix == null) ? name: `${prefix}_${name}`);
///
export const products = pgTable("products", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 255 }).notNull(),
description: text().notNull(),
price: numeric().notNull(),
resellerId: integer("reseller_id").notNull(),
categoryId: integer("category_id").notNull(),
stockQty: integer("stock_qty").default(0),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
});
export const categories = pgTable("categories", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "categories_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 100 }).notNull(),
parentCategoryId: integer("parent_category_id"),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
});
export const productFeeds = pgTable("product_feeds", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "productfeeds_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
resellerId: integer("reseller_id").notNull(),
feedUrl: varchar("feed_url", { length: 255 }).notNull(),
lastUpdate: timestamp("last_update", { mode: 'string' }),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
productFeedsUuidUnique: unique("product_feeds_uuid_unique").on(table.uuid),
}
});
export const userActivityLog = pgTable("user_activity_log", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "user_activity_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
userId: bigint("user_id", { mode: "number" }).notNull(),
activity: text().notNull(),
timestamp: timestamp({ mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
});
export const brands = pgTable("brands", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "brands_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 100 }).notNull(),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
brandsUuidUnique: unique("brands_uuid_unique").on(table.uuid),
}
});
export const manufacturer = pgTable("manufacturer", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "manufacturer_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 100 }).notNull(),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
manufacturerUuidUnique: unique("manufacturer_uuid_unique").on(table.uuid),
}
});
export const states = pgTable("states", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "states_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
state: varchar({ length: 50 }),
abbreviation: varchar({ length: 50 }),
});
export const componentType = pgTable("component_type", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "component_type_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 100 }).notNull(),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
componentTypeUuidUnique: unique("component_type_uuid_unique").on(table.uuid),
}
});
export const aeroPrecision = pgTable("aero_precision", {
sku: text().primaryKey().notNull(),
manufacturerId: text("manufacturer_id"),
brandName: text("brand_name"),
productName: text("product_name"),
longDescription: text("long_description"),
shortDescription: text("short_description"),
department: text(),
category: text(),
subcategory: text(),
thumbUrl: text("thumb_url"),
imageUrl: text("image_url"),
buyLink: text("buy_link"),
keywords: text(),
reviews: text(),
retailPrice: numeric("retail_price"),
salePrice: numeric("sale_price"),
brandPageLink: text("brand_page_link"),
brandLogoImage: text("brand_logo_image"),
productPageViewTracking: text("product_page_view_tracking"),
variantsXml: text("variants_xml"),
mediumImageUrl: text("medium_image_url"),
productContentWidget: text("product_content_widget"),
googleCategorization: text("google_categorization"),
itemBasedCommission: text("item_based_commission"),
uuid: uuid().defaultRandom(),
});
export const compartment = pgTable("compartment", {
id: uuid().defaultRandom().primaryKey().notNull(),
name: varchar({ length: 100 }).notNull(),
description: varchar({ length: 300 }),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
});
export const builds = pgTable("builds", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
accountId: integer("account_id").notNull(),
name: varchar({ length: 255 }).notNull(),
description: text(),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
buildsUuidUnique: unique("builds_uuid_unique").on(table.uuid),
}
});
export const bb_products = pgTable("bb_products", {
uuid: uuid().defaultRandom().primaryKey().notNull(),
upc: varchar("UPC", { length: 100 }),
sku: varchar("SKU", { length: 50 }),
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
brandName: varchar("BRAND_NAME", { length: 50 }),
productName: varchar("PRODUCT_NAME", { length: 255 }),
longDescription: text("LONG_DESCRIPTION"),
shortDescription: varchar("SHORT_DESCRIPTION", { length: 500 }),
department: varchar("DEPARTMENT", { length: 100 }),
category: varchar("CATEGORY", { length: 100 }),
subcategory: varchar("SUBCATEGORY", { length: 100 }),
thumbUrl: varchar("THUMB_URL", { length: 500 }),
imageUrl: varchar("IMAGE_URL", { length: 500 }),
buyLink: varchar("BUY_LINK", { length: 500 }),
keywords: varchar("KEYWORDS", { length: 500 }),
reviews: varchar("REVIEWS", { length: 500 }),
retailPrice: varchar("RETAIL_PRICE", { length: 50 }),
salePrice: varchar("SALE_PRICE", { length: 50 }),
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 500 }),
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 500 }),
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 500 }),
parentGroupId: varchar("PARENT_GROUP_ID", { length: 200 }),
fineline: varchar("FINELINE", { length: 200 }),
superfineline: varchar("SUPERFINELINE", { length: 200 }),
modelnumber: varchar("MODELNUMBER", { length: 100 }),
caliber: varchar("CALIBER", { length: 200 }),
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 500 }),
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 500 }),
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 500 }),
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 500 }),
itemBasedCommissionRate: varchar("ITEM_BASED_COMMISSION RATE", { length: 50 }),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
});
export const psa_old = pgTable("psa_old", {
sku: varchar("SKU", { length: 50 }),
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
brandName: varchar("BRAND_NAME", { length: 50 }),
productName: varchar("PRODUCT_NAME", { length: 255 }),
longDescription: text("LONG_DESCRIPTION"),
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
department: varchar("DEPARTMENT", { length: 50 }),
category: varchar("CATEGORY", { length: 50 }),
subcategory: varchar("SUBCATEGORY", { length: 50 }),
thumbUrl: varchar("THUMB_URL", { length: 50 }),
imageUrl: varchar("IMAGE_URL", { length: 50 }),
buyLink: varchar("BUY_LINK", { length: 128 }),
keywords: varchar("KEYWORDS", { length: 50 }),
reviews: varchar("REVIEWS", { length: 50 }),
retailPrice: real("RETAIL_PRICE"),
salePrice: real("SALE_PRICE"),
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
fineline: varchar("FINELINE", { length: 50 }),
superfineline: varchar("SUPERFINELINE", { length: 200 }),
modelnumber: varchar("MODELNUMBER", { length: 50 }),
caliber: varchar("CALIBER", { length: 200 }),
upc: varchar("UPC", { length: 100 }),
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
uuid: uuid().defaultRandom(),
});
export const psa = pgTable("psa", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "psa_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
sku: varchar("SKU", { length: 50 }),
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
brandName: varchar("BRAND_NAME", { length: 50 }),
productName: varchar("PRODUCT_NAME", { length: 255 }),
longDescription: text("LONG_DESCRIPTION"),
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
department: varchar("DEPARTMENT", { length: 50 }),
category: varchar("CATEGORY", { length: 50 }),
subcategory: varchar("SUBCATEGORY", { length: 50 }),
thumbUrl: varchar("THUMB_URL", { length: 50 }),
imageUrl: varchar("IMAGE_URL", { length: 50 }),
buyLink: varchar("BUY_LINK", { length: 128 }),
keywords: varchar("KEYWORDS", { length: 50 }),
reviews: varchar("REVIEWS", { length: 50 }),
retailPrice: real("RETAIL_PRICE"),
salePrice: real("SALE_PRICE"),
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
fineline: varchar("FINELINE", { length: 50 }),
superfineline: varchar("SUPERFINELINE", { length: 200 }),
modelnumber: varchar("MODELNUMBER", { length: 50 }),
caliber: varchar("CALIBER", { length: 200 }),
upc: varchar("UPC", { length: 100 }),
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
uuid: uuid().defaultRandom(),
});
export const lipseycatalog = pgTable("lipseycatalog", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "lipseycatalog_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
itemno: varchar({ length: 20 }).notNull(),
description1: text(),
description2: text(),
upc: varchar({ length: 20 }),
manufacturermodelno: varchar({ length: 30 }),
msrp: doublePrecision(),
model: text(),
calibergauge: text(),
manufacturer: text(),
type: text(),
action: text(),
barrellength: text(),
capacity: text(),
finish: text(),
overalllength: text(),
receiver: text(),
safety: text(),
sights: text(),
stockframegrips: text(),
magazine: text(),
weight: text(),
imagename: text(),
chamber: text(),
drilledandtapped: text(),
rateoftwist: text(),
itemtype: text(),
additionalfeature1: text(),
additionalfeature2: text(),
additionalfeature3: text(),
shippingweight: text(),
boundbookmanufacturer: text(),
boundbookmodel: text(),
boundbooktype: text(),
nfathreadpattern: text(),
nfaattachmentmethod: text(),
nfabaffletype: text(),
silencercanbedisassembled: text(),
silencerconstructionmaterial: text(),
nfadbreduction: text(),
silenceroutsidediameter: text(),
nfaform3Caliber: text(),
opticmagnification: text(),
maintubesize: text(),
adjustableobjective: text(),
objectivesize: text(),
opticadjustments: text(),
illuminatedreticle: text(),
reticle: text(),
exclusive: text(),
quantity: varchar({ length: 10 }).default(sql`NULL`),
allocated: text(),
onsale: text(),
price: doublePrecision(),
currentprice: doublePrecision(),
retailmap: doublePrecision(),
fflrequired: text(),
sotrequired: text(),
exclusivetype: text(),
scopecoverincluded: text(),
special: text(),
sightstype: text(),
case: text(),
choke: text(),
dbreduction: text(),
family: text(),
finishtype: text(),
frame: text(),
griptype: varchar({ length: 30 }),
handgunslidematerial: text(),
countryoforigin: varchar({ length: 4 }),
itemlength: text(),
itemwidth: text(),
itemheight: text(),
packagelength: doublePrecision(),
packagewidth: doublePrecision(),
packageheight: doublePrecision(),
itemgroup: varchar({ length: 40 }),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
});
export const buildsComponents = pgTable("builds_components", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_components_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
buildId: integer("build_id").notNull(),
productId: integer("product_id").notNull(),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
buildsComponentsUuidUnique: unique("builds_components_uuid_unique").on(table.uuid),
}
});
export const balResellers = pgTable("bal_resellers", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "resellers_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 100 }).notNull(),
websiteUrl: varchar("website_url", { length: 255 }),
contactEmail: varchar("contact_email", { length: 100 }),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp("deleted_at", { mode: 'string' }),
uuid: uuid().defaultRandom(),
}, (table) => {
return {
balResellersUuidUnique: unique("bal_resellers_uuid_unique").on(table.uuid),
}
});
export const verificationTokens = pgTable("verificationTokens", {
identifier: varchar("identifier").notNull(),
token: varchar("token").notNull(),
expires: timestamp("expires").notNull(),
});
export const authenticator = pgTable("authenticator", {
credentialId: text().notNull(),
userId: text().notNull(),
providerAccountId: text().notNull(),
credentialPublicKey: text().notNull(),
counter: integer().notNull(),
credentialDeviceType: text().notNull(),
credentialBackedUp: boolean().notNull(),
transports: text(),
}, (table) => {
return {
authenticatorUserIdCredentialIdPk: primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
authenticatorCredentialIdUnique: unique("authenticator_credentialID_unique").on(table.credentialId),
}
});
export const accounts = pgTable("accounts", {
id: uuid("id").primaryKey().defaultRandom(),
uuid: uuid("uuid").defaultRandom(),
userId: uuid("user_id").notNull(),
type: varchar("type").notNull(),
provider: text().notNull(),
providerAccountId: varchar("provider_account_id").notNull(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: integer("expires_at"),
tokenType: varchar("token_type"),
idToken: text("id_token"),
sessionState: varchar("session_state"),
scope: text(),
}
);
/* export const vw_accounts = pgView("vw_accounts", {
uuid: uuid().defaultRandom(),
userId: text("user_id").notNull(),
type: text().notNull(),
provider: text().notNull(),
providerAccountId: text("provider_account_id").notNull(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: integer("expires_at"),
tokenType: text("token_type"),
scope: text(),
idToken: text("id_token"),
sessionState: text("session_state"),
first_name: text("first_name"),
last_name: text("last_name"),
},) */
/* From here down is the authentication library Lusia tables */
export const users = pgTable("user",
{
id: varchar("id", { length: 21 }).primaryKey(),
name: varchar("name"),
username: varchar({ length: 50 }),
discordId: varchar("discord_id", { length: 255 }).unique(),
email: varchar("email", { length: 255 }).unique().notNull(),
emailVerified: boolean("email_verified").default(false).notNull(),
hashedPassword: varchar("hashed_password", { length: 255 }),
first_name: varchar("first_name", { length: 50 }),
last_name: varchar("last_name", { length: 50 }),
full_name: varchar("full_name", { length: 50 }),
profilePicture: varchar("profile_picture", { length: 255 }),
image: text("image"),
dateOfBirth: date("date_of_birth"),
phoneNumber: varchar("phone_number", { length: 20 }),
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
isAdmin: boolean("is_admin").default(false),
lastLogin: timestamp("last_login", { mode: 'string' }),
buildPrivacySetting: text("build_privacy_setting").default('public'),
uuid: uuid().defaultRandom(),
avatar: varchar("avatar", { length: 255 }),
stripeSubscriptionId: varchar("stripe_subscription_id", { length: 191 }),
stripePriceId: varchar("stripe_price_id", { length: 191 }),
stripeCustomerId: varchar("stripe_customer_id", { length: 191 }),
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
}, (table) => ({
usersUsernameKey: unique("users_username_key").on(table.username),
usersEmailKey: unique("users_email_key").on(table.email),
usersBuildPrivacySettingCheck: check("users_build_privacy_setting_check", sql`build_privacy_setting = ANY (ARRAY['private'::text, 'public'::text])`),
emailIdx: index("user_email_idx").on(table.email),
discordIdx: index("user_discord_idx").on(table.discordId),
}),
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export const session = pgTable(
"session",
{
sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(),
userId: varchar("userId", { length: 21 }).notNull(),
expires: timestamp("expires", { withTimezone: true, mode: "date" }).notNull(),
}
);
export const emailVerificationCodes = pgTable(
"email_verification_codes",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 21 }).unique().notNull(),
email: varchar("email", { length: 255 }).notNull(),
code: varchar("code", { length: 8 }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
},
(t) => ({
userIdx: index("verification_code_user_idx").on(t.userId),
emailIdx: index("verification_code_email_idx").on(t.email),
}),
);
export const passwordResetTokens = pgTable(
"password_reset_tokens",
{
id: varchar("id", { length: 40 }).primaryKey(),
userId: varchar("user_id", { length: 21 }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
},
(t) => ({
userIdx: index("password_token_user_idx").on(t.userId),
}),
);
export const posts = pgTable(
"posts",
{
id: varchar("id", { length: 15 }).primaryKey(),
userId: varchar("user_id", { length: 255 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
excerpt: varchar("excerpt", { length: 255 }).notNull(),
content: text("content").notNull(),
status: varchar("status", { length: 10, enum: ["draft", "published"] })
.default("draft")
.notNull(),
tags: varchar("tags", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
},
(t) => ({
userIdx: index("post_user_idx").on(t.userId),
createdAtIdx: index("post_created_at_idx").on(t.createdAt),
}),
);
export const postRelations = relations(posts, ({ one }) => ({
user: one(users, {
fields: [posts.userId],
references: [users.id],
}),
}));
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export const vwUserSessions = pgView("vw_user_sessions", { id: varchar({ length: 255 }),
userId: varchar("user_id", { length: 21 }),
uId: varchar("u_id", { length: 21 }),
uEmail: varchar("u_email", { length: 255 }),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }),
createdAt: timestamp("created_at", { mode: 'string' }),
updatedAt: timestamp("updated_at", { mode: 'string' }),
}).existing();
//as(sql`SELECT s.id, s.user_id, u.id AS u_id, u.email AS u_email, s.expires_at, s.created_at, s.updated_at FROM sessions s, users u WHERE s.user_id::text = u.id::text`);
// Default Drizzle File
// import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
// export const products = pgTable("products", {
// id: serial("id").primaryKey(),
// name: text("name").notNull(),
// description: text("description"),
// price: integer("price"),
// createdAt: timestamp("created_at").defaultNow(),
// // Add more fields as needed
// });
export const affiliateCategoryMap = pgTable("affiliate_category_map", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "affiliate_category_map_id_seq", startWith: 1, increment: 1 }),
feedname: varchar("feedname", { length: 100 }).notNull(),
affiliatecategory: varchar("affiliatecategory", { length: 255 }).notNull(),
buildercategoryid: integer("buildercategoryid").notNull(),
notes: varchar("notes", { length: 255 }),
});
export const product_categories = pgTable("product_categories", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "product_categories_id_seq", startWith: 1, increment: 1 }),
name: varchar({ length: 100 }).notNull(),
parent_category_id: integer("parent_category_id"),
type: varchar({ length: 50 }),
sort_order: integer("sort_order"),
created_at: timestamp("created_at", { mode: 'string' }).defaultNow(),
updated_at: timestamp("updated_at", { mode: 'string' }).defaultNow(),
});
+40
View File
@@ -0,0 +1,40 @@
const constants = {
APP_NAME: 'Ballistic Builder',
SITE_NAME: 'Ballistic Builder',
COMPANY_NAME: 'Forward Group, LLC',
COMPANY_URL: 'https://goforward.group',
AUTHOR: 'Forward Group, LLC',
META_KEYWORDS: 'Pew Pew',
META_DESCRIPTION: 'Pow Pow',
DESCRIPTION: 'Developed by Forward Group, LLC',
PJAM_RAINIER: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8713',
PJAM_BARRETTA: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8342'
};
export default constants;
export enum SITE_CONT_TYPE {
CONTACTUS = "CONTACTUS",
PRIVACYPOLICY = "PP",
PERSONALINFOPOLICY = "PIP",
FAQ = "FAQ",
TERMSOFSERVICE = "TOS",
ABOUTUS="ABOUTUS",
DISCLOSURE="DISCLOSURE"
}
export const APP_TITLE = "Ballistics Builder";
export const DATABASE_PREFIX = "";
export const TEST_DB_PREFIX = "test_acme";
export const EMAIL_SENDER = '"Ballistics Builder" <don@goforward.group>';
export enum Paths {
Home = "/",
Login = "/login",
Signup = "/signup",
Dashboard = "/dashboard",
VerifyEmail = "/verify-email",
ResetPassword = "/reset-password",
}
+532 -767
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { Session } from 'next-auth';
interface AuthStore {
session: Session | null;
isLoading: boolean;
setSession: (session: Session | null) => void;
setLoading: (isLoading: boolean) => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
session: null,
isLoading: true,
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
}));
+53
View File
@@ -0,0 +1,53 @@
import { create, StateCreator } from 'zustand';
import { persist } from 'zustand/middleware';
export interface BuildPart {
id: string;
name: string;
image_url: string;
brand: {
id: string;
name: string;
logo?: string;
};
category: {
id: string;
name: string;
};
offers: Array<{
price: number;
url: string;
vendor: {
name: string;
logo?: string;
};
inStock?: boolean;
shipping?: string;
}>;
}
export interface BuildState {
selectedParts: Record<string, BuildPart | null>; // key: checklist component id
selectPartForComponent: (componentId: string, part: BuildPart) => void;
removePartForComponent: (componentId: string) => void;
clearBuild: () => void;
}
const buildStoreCreator: StateCreator<BuildState> = (set) => ({
selectedParts: {},
selectPartForComponent: (componentId, part) => set((state) => ({
selectedParts: { ...state.selectedParts, [componentId]: part },
})),
removePartForComponent: (componentId) => set((state) => {
const updated = { ...state.selectedParts };
delete updated[componentId];
return { selectedParts: updated };
}),
clearBuild: () => set({ selectedParts: {} }),
});
export const useBuildStore = create<BuildState>()(
persist(buildStoreCreator, {
name: 'current-build-storage',
})
);
+7 -119
View File
@@ -1,120 +1,8 @@
console.log('DaisyUI plugin loaded');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Secondary accent colors
accent: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
// Neutral grays with warm undertones
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
// Success colors
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// Warning colors
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
{
pew: {
primary: '#4B6516', // Olive/army green
'primary-content': '#fff',
accent: '#181C20', // Dark navy for CTA/footer
'accent-content': '#fff',
neutral: '#222',
'base-100': '#fff',
'base-200': '#f5f6fa',
'base-300': '#e5e7eb',
info: '#3ABFF8',
success: '#36D399',
warning: '#FBBD23',
error: '#F87272',
},
},
'dark',
],
darkTheme: "dark",
base: true,
styled: true,
utils: true,
logs: false,
rtl: false,
prefix: '',
// 'pew' is the default theme
},
};
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: { extend: {} },
plugins: [],
}