Compare commits
8 Commits
main
..
data-connect
| Author | SHA1 | Date | |
|---|---|---|---|
| b478d9797d | |||
| 5c046874a8 | |||
| c3151f380b | |||
| 4798c1139e | |||
| 41e55404bf | |||
| ccc6e41724 | |||
| 64f288d8f7 | |||
| 14b25e7359 |
@@ -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
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ["www.brownells.com"],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1749
-1291
File diff suppressed because it is too large
Load Diff
+18
-8
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,38 +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>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
{/* Search Row */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<SearchInput
|
||||
label="Search Components"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search components..."
|
||||
/>
|
||||
{/* Clear Build Modal */}
|
||||
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
|
||||
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
|
||||
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
|
||||
<Dialog.Description className="mb-4 text-gray-600">
|
||||
Are you sure you want to clear your entire build? This action cannot be undone.
|
||||
</Dialog.Description>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => setShowClearModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-error"
|
||||
onClick={() => {
|
||||
clearBuild();
|
||||
setShowClearModal(false);
|
||||
}}
|
||||
>
|
||||
Yes, Clear Build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Restriction Alerts */}
|
||||
{restrictedParts.length > 0 && (
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRestrictionAlerts(!showRestrictionAlerts)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||
>
|
||||
{showRestrictionAlerts ? 'Hide' : 'Show'} details
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showRestrictionAlerts ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRestrictionAlerts && (
|
||||
<div className="space-y-2">
|
||||
{hasNFAItems && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🔒</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">NFA Items in Your Build</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Your build contains items that require National Firearms Act registration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSuppressors && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🔇</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">Suppressor in Your Build</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Sound suppressor requires NFA registration. Processing times: 6-12 months.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasStateRestrictions && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">🗺️</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-yellow-800">State Restrictions Apply</div>
|
||||
<div className="text-xs text-yellow-700 mt-1">
|
||||
Some items may be restricted in certain states. Verify local laws.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<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"
|
||||
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}>
|
||||
@@ -362,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">
|
||||
<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>
|
||||
@@ -373,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"
|
||||
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>
|
||||
@@ -388,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>
|
||||
@@ -399,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
|
||||
@@ -425,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>
|
||||
@@ -441,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>
|
||||
@@ -458,81 +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={`/?category=${encodeURIComponent(component.category)}`}
|
||||
className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Find Parts
|
||||
</Link>
|
||||
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{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} · {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>
|
||||
);
|
||||
})
|
||||
@@ -562,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>
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
|
||||
// Sample build data
|
||||
const sampleBuilds = [
|
||||
const sampleMyBuilds = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Budget AR-15 Build',
|
||||
@@ -121,18 +121,18 @@ const sampleBuilds = [
|
||||
}
|
||||
];
|
||||
|
||||
type BuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function BuildsPage() {
|
||||
export default function MyBuildsPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('completedDate');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [selectedStatus, setSelectedStatus] = useState<BuildStatus | 'all'>('all');
|
||||
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter builds
|
||||
const filteredBuilds = sampleBuilds.filter(build => {
|
||||
const filteredMyBuilds = sampleMyBuilds.filter(build => {
|
||||
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export default function BuildsPage() {
|
||||
});
|
||||
|
||||
// Sort builds
|
||||
const sortedBuilds = [...filteredBuilds].sort((a, b) => {
|
||||
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
|
||||
if (sortField === 'totalCost') {
|
||||
@@ -170,7 +170,7 @@ export default function BuildsPage() {
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusColor = (status: BuildStatus) => {
|
||||
const getStatusColor = (status: MyBuildStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
@@ -183,7 +183,7 @@ export default function BuildsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: BuildStatus) => {
|
||||
const getStatusIcon = (status: MyBuildStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '✓';
|
||||
@@ -204,14 +204,14 @@ export default function BuildsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const getProgressPercentage = (build: typeof sampleBuilds[0]) => {
|
||||
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
|
||||
return Math.round((build.components.completed / build.components.total) * 100);
|
||||
};
|
||||
|
||||
const totalBuilds = sampleBuilds.length;
|
||||
const completedBuilds = sampleBuilds.filter(build => build.status === 'completed').length;
|
||||
const inProgressBuilds = sampleBuilds.filter(build => build.status === 'in-progress').length;
|
||||
const totalValue = sampleBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
||||
const totalMyBuilds = sampleMyBuilds.length;
|
||||
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
|
||||
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
|
||||
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
@@ -228,15 +228,15 @@ export default function BuildsPage() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{totalBuilds}</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">Total Builds</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completedBuilds}</div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressBuilds}</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">In Progress</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
@@ -254,10 +254,10 @@ export default function BuildsPage() {
|
||||
<div className="mb-4 flex justify-end">
|
||||
<div className="w-1/2">
|
||||
<SearchInput
|
||||
label="Search Builds"
|
||||
label="Search My Builds"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search builds..."
|
||||
placeholder="Search my builds..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ export default function BuildsPage() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as BuildStatus | 'all')}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
@@ -319,9 +319,9 @@ export default function BuildsPage() {
|
||||
|
||||
{/* Builds Grid */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{sortedBuilds.length > 0 ? (
|
||||
{sortedMyBuilds.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedBuilds.map((build) => (
|
||||
{sortedMyBuilds.map((build) => (
|
||||
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
|
||||
{/* Build Image */}
|
||||
<div className="h-48 bg-gray-200 relative">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
|
||||
// Sample build data
|
||||
const sampleMyBuilds = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Budget AR-15 Build',
|
||||
description: 'A cost-effective AR-15 build using quality budget components',
|
||||
status: 'completed' as const,
|
||||
totalCost: 847.50,
|
||||
completedDate: '2024-01-15',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 18,
|
||||
categories: {
|
||||
'Upper': 8,
|
||||
'Lower': 7,
|
||||
'Accessory': 3
|
||||
}
|
||||
},
|
||||
tags: ['Budget', '5.56 NATO', '16" Barrel'],
|
||||
image: 'https://picsum.photos/400/250?random=1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Precision Long Range',
|
||||
description: 'High-end precision build optimized for long-range accuracy',
|
||||
status: 'in-progress' as const,
|
||||
totalCost: 2847.99,
|
||||
startedDate: '2024-02-01',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 12,
|
||||
categories: {
|
||||
'Upper': 6,
|
||||
'Lower': 4,
|
||||
'Accessory': 2
|
||||
}
|
||||
},
|
||||
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
|
||||
image: 'https://picsum.photos/400/250?random=2'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Home Defense Setup',
|
||||
description: 'Compact AR-15 configured for home defense scenarios',
|
||||
status: 'planning' as const,
|
||||
totalCost: 0,
|
||||
plannedDate: '2024-03-01',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 0,
|
||||
categories: {
|
||||
'Upper': 0,
|
||||
'Lower': 0,
|
||||
'Accessory': 0
|
||||
}
|
||||
},
|
||||
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
|
||||
image: 'https://picsum.photos/400/250?random=3'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Competition Rifle',
|
||||
description: 'Lightweight competition build for 3-gun matches',
|
||||
status: 'completed' as const,
|
||||
totalCost: 1650.75,
|
||||
completedDate: '2023-12-10',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 18,
|
||||
categories: {
|
||||
'Upper': 8,
|
||||
'Lower': 7,
|
||||
'Accessory': 3
|
||||
}
|
||||
},
|
||||
tags: ['Competition', '5.56 NATO', '18" Barrel'],
|
||||
image: 'https://picsum.photos/400/250?random=4'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Suppressed SBR',
|
||||
description: 'Short-barreled rifle build with suppressor integration',
|
||||
status: 'in-progress' as const,
|
||||
totalCost: 1895.25,
|
||||
startedDate: '2024-01-20',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 8,
|
||||
categories: {
|
||||
'Upper': 4,
|
||||
'Lower': 3,
|
||||
'Accessory': 1
|
||||
}
|
||||
},
|
||||
tags: ['SBR', 'Suppressed', '300 BLK'],
|
||||
image: 'https://picsum.photos/400/250?random=5'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Retro M16A1 Clone',
|
||||
description: 'Faithful reproduction of the classic M16A1 rifle',
|
||||
status: 'planning' as const,
|
||||
totalCost: 0,
|
||||
plannedDate: '2024-04-01',
|
||||
components: {
|
||||
total: 18,
|
||||
completed: 0,
|
||||
categories: {
|
||||
'Upper': 0,
|
||||
'Lower': 0,
|
||||
'Accessory': 0
|
||||
}
|
||||
},
|
||||
tags: ['Retro', '5.56 NATO', '20" Barrel'],
|
||||
image: 'https://picsum.photos/400/250?random=6'
|
||||
}
|
||||
];
|
||||
|
||||
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
|
||||
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function MyBuildsPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('completedDate');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter builds
|
||||
const filteredMyBuilds = sampleMyBuilds.filter(build => {
|
||||
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort builds
|
||||
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
|
||||
if (sortField === 'totalCost') {
|
||||
aValue = a.totalCost;
|
||||
bValue = b.totalCost;
|
||||
} else if (sortField === 'status') {
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
} else if (sortField === 'completedDate') {
|
||||
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
|
||||
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
|
||||
} else {
|
||||
aValue = a.name.toLowerCase();
|
||||
bValue = b.name.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusColor = (status: MyBuildStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'in-progress':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'planning':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: MyBuildStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '✓';
|
||||
case 'in-progress':
|
||||
return '🔄';
|
||||
case 'planning':
|
||||
return '📋';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
|
||||
return Math.round((build.components.completed / build.components.total) * 100);
|
||||
};
|
||||
|
||||
const totalMyBuilds = sampleMyBuilds.length;
|
||||
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
|
||||
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
|
||||
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
{/* Page Title */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">My Builds</h1>
|
||||
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Summary */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">Total Builds</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">In Progress</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-500">Total Value</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="planning">Planning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort by */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
||||
<select
|
||||
value={sortField}
|
||||
onChange={(e) => setSortField(e.target.value as SortField)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="completedDate">Date</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="totalCost">Cost</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Direction */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
|
||||
<select
|
||||
value={sortDirection}
|
||||
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* New Build Button */}
|
||||
<div className="flex items-end">
|
||||
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
+ New Build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Builds Grid */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{sortedMyBuilds.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedMyBuilds.map((build) => (
|
||||
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
|
||||
{/* Build Image */}
|
||||
<div className="h-48 bg-gray-200 relative">
|
||||
<img
|
||||
src={build.image}
|
||||
alt={build.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
|
||||
<div className="text-center text-gray-600">
|
||||
<div className="text-4xl mb-2">🔫</div>
|
||||
<div className="text-sm font-medium">{build.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
|
||||
{getStatusIcon(build.status)} {build.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Content */}
|
||||
<div className="p-6">
|
||||
{/* Build Title and Date */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
|
||||
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
|
||||
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{build.components.completed}/{build.components.total} components</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${getProgressPercentage(build)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Categories */}
|
||||
<div className="mb-4">
|
||||
<div className="flex space-x-2">
|
||||
{Object.entries(build.components.categories).map(([category, count]) => (
|
||||
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
|
||||
{category}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{build.tags.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost and Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
${build.totalCost.toFixed(2)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
|
||||
View Details
|
||||
</button>
|
||||
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">No builds found</div>
|
||||
<div className="text-sm">Try adjusting your filters or create a new build</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import BetaTester from "@/components/BetaTester";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="bg-white font-sans">
|
||||
{/* SVG Grid Background */}
|
||||
<div className="relative isolate pt-1">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M100 200V.5M.5 .5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-start lg:gap-x-10 lg:px-8 lg:py-40">
|
||||
{/* Left: Headline, Subheading, Button */}
|
||||
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
|
||||
<h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
|
||||
A better way to plan your next build
|
||||
</h1>
|
||||
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
|
||||
Welcome to Pew Builder – the modern way to discover, compare, and assemble firearm parts. Start your build, explore top brands, and find the perfect components for your next project.
|
||||
</p>
|
||||
<div className="mt-10 flex items-top gap-x-6">
|
||||
<Link
|
||||
href="/build"
|
||||
className="btn btn-primary text-base font-semibold px-6"
|
||||
>
|
||||
Get Building
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Product Image */}
|
||||
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center group">
|
||||
<img
|
||||
alt="AR-15 Lower Receiver"
|
||||
src="https://i.imgur.com/IK8FbaI.png"
|
||||
className="max-w-md w-full h-auto object-contain rounded-xl transition-transform duration-500 ease-in-out animate-float group-hover:-translate-y-2"
|
||||
style={{ willChange: 'transform' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Beta Tester CTA */}
|
||||
<BetaTester />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AdminNavbar from './AdminNavbar';
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AdminNavbar>
|
||||
{children}
|
||||
</AdminNavbar>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+48
-46
@@ -3,54 +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;
|
||||
}
|
||||
|
||||
::-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-500 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 */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
|
||||
}
|
||||
|
||||
.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-500 focus:border-primary-500 transition-colors duration-200;
|
||||
}
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
+2
-9
@@ -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"] });
|
||||
|
||||
@@ -17,14 +15,9 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon } 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';
|
||||
|
||||
// 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')}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
||||
onClick={() => setViewMode('cards')}
|
||||
>
|
||||
Cards
|
||||
</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">
|
||||
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('name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Name</span>
|
||||
<span className="text-sm">{getSortIcon('name')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Brand
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Description
|
||||
</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">
|
||||
<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-medium text-neutral-900 dark:text-white">
|
||||
{part.name}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-neutral-900 dark:text-white">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
{part.description}
|
||||
</div>
|
||||
</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">
|
||||
View Details
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export default function BetaTester() {
|
||||
return (
|
||||
<div className="bg-gray-900 py-16 sm:py-24 lg:py-32">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 lg:grid-cols-12 lg:gap-8 lg:px-8">
|
||||
<h2 className="max-w-xl text-balance text-3xl font-semibold tracking-tight text-white sm:text-4xl lg:col-span-7">
|
||||
Interested in being a beta tester? Join the beta tester list.
|
||||
</h2>
|
||||
<form className="w-full max-w-md lg:col-span-5 lg:pt-2">
|
||||
<div className="flex gap-x-4">
|
||||
<label htmlFor="email-address" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
autoComplete="email"
|
||||
className="min-w-0 flex-auto rounded-md bg-white/5 px-3.5 py-2 text-base text-white outline outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-none rounded-md bg-primary px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
Join The List
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm/6 text-gray-300">
|
||||
We care about your data. Read our{' '}
|
||||
<a href="#" className="font-semibold text-white">
|
||||
privacy policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+139
-55
@@ -3,42 +3,153 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import ThemeSwitcher from './ThemeSwitcher';
|
||||
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: '/', label: 'Parts Catalog' },
|
||||
{ href: '/build', label: 'Build Checklist' },
|
||||
{ href: '/builds', label: 'My Builds' },
|
||||
{ href: '/parts', label: 'Parts Catalog' },
|
||||
{ href: '/build', label: 'Plan a Build' },
|
||||
{ href: '/my-builds', label: 'My Builds' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-primary-600 dark:bg-primary-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">🔫</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||
Pew Builder
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
// 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);
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
return (
|
||||
<>
|
||||
{/* Top Bar */}
|
||||
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
||||
<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 */}
|
||||
<nav 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 flex justify-between items-center h-14">
|
||||
{/* Left: Nav Links */}
|
||||
<div className="flex items-center space-x-6">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300'
|
||||
: 'text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
||||
? 'text-primary font-semibold underline underline-offset-4'
|
||||
: 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
@@ -46,41 +157,14 @@ export default function Navbar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Theme Switcher */}
|
||||
{/* Right: Search */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<ThemeSwitcher />
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button className="md:hidden p-2 rounded-md text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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 });
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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",
|
||||
}
|
||||
+533
-768
File diff suppressed because it is too large
Load Diff
@@ -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 }),
|
||||
}));
|
||||
@@ -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
-153
@@ -1,154 +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: {
|
||||
// Primary brand colors
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
// 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: [
|
||||
{
|
||||
light: {
|
||||
"primary": "#0ea5e9",
|
||||
"primary-content": "#ffffff",
|
||||
"secondary": "#ef4444",
|
||||
"secondary-content": "#ffffff",
|
||||
"accent": "#f59e0b",
|
||||
"accent-content": "#ffffff",
|
||||
"neutral": "#737373",
|
||||
"neutral-content": "#ffffff",
|
||||
"base-100": "#ffffff",
|
||||
"base-200": "#f5f5f5",
|
||||
"base-300": "#e5e5e5",
|
||||
"base-content": "#171717",
|
||||
"info": "#0ea5e9",
|
||||
"success": "#22c55e",
|
||||
"warning": "#f59e0b",
|
||||
"error": "#ef4444",
|
||||
},
|
||||
dark: {
|
||||
"primary": "#38bdf8",
|
||||
"primary-content": "#ffffff",
|
||||
"secondary": "#f87171",
|
||||
"secondary-content": "#ffffff",
|
||||
"accent": "#fbbf24",
|
||||
"accent-content": "#ffffff",
|
||||
"neutral": "#a3a3a3",
|
||||
"neutral-content": "#ffffff",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-300": "#404040",
|
||||
"base-content": "#ffffff",
|
||||
"info": "#38bdf8",
|
||||
"success": "#4ade80",
|
||||
"warning": "#fbbf24",
|
||||
"error": "#f87171",
|
||||
},
|
||||
},
|
||||
],
|
||||
darkTheme: "dark",
|
||||
base: true,
|
||||
styled: true,
|
||||
utils: true,
|
||||
prefix: "",
|
||||
logs: true,
|
||||
themeRoot: ":root",
|
||||
},
|
||||
};
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user