fucking pos. data is working but tailwind is fucked

This commit is contained in:
2025-06-30 13:02:19 -04:00
parent 4798c1139e
commit c3151f380b
27 changed files with 2734 additions and 1792 deletions

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;

8
next.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["www.brownells.com"],
},
};
module.exports = nextConfig;

View File

@@ -1,9 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [],
},
};
export default nextConfig;

2761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,23 +12,26 @@
"@auth/core": "^0.34.2", "@auth/core": "^0.34.2",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.21",
"daisyui": "^4.7.3", "daisyui": "^4.7.3",
"next": "15.3.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2",
"next": "^14.2.30",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"react": "^19.0.0", "pg": "^8.16.3",
"react-dom": "^19.0.0", "react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "^3.4.3",
"zustand": "^5.0.6" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/pg": "^8.15.4",
"@types/react-dom": "^19", "@types/react": "^18.2.0",
"autoprefixer": "^10.4.21", "@types/react-dom": "^18.2.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.4", "eslint-config-next": "15.3.4",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.4",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; }

View File

@@ -13,7 +13,7 @@ export default function ForgotPasswordPage() {
return ( return (
<div className="flex flex-1 items-center justify-center min-h-[60vh]"> <div className="flex flex-1 items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8"> <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> <h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm"> <p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/> Enter your email address and we'll send you a link to reset your password.<br/>
@@ -24,7 +24,7 @@ export default function ForgotPasswordPage() {
type="email" type="email"
required required
placeholder="Email address" placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500" 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} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
disabled={submitted} disabled={submitted}

View File

@@ -8,7 +8,7 @@ export default function AccountLayout({
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* Simple navbar with back button */} {/* Simple navbar with back button */}
<nav className="bg-white dark:bg-neutral-900 shadow-sm"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-start h-16"> <div className="flex justify-start h-16">
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -49,7 +49,7 @@ export default function LoginPage() {
/> />
</div> </div>
{/* Right side form */} {/* 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-neutral-900 min-h-screen"> <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 className="mx-auto w-full max-w-md space-y-8">
<div> <div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2> <h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
@@ -75,7 +75,7 @@ export default function LoginPage() {
required required
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm" 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" placeholder="Email address"
disabled={loading} disabled={loading}
/> />
@@ -92,7 +92,7 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm" 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" placeholder="Password"
disabled={loading} disabled={loading}
/> />
@@ -139,10 +139,10 @@ export default function LoginPage() {
<div className="mt-6"> <div className="mt-6">
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-neutral-700" /> <div className="w-full border-t border-gray-300 dark:border-zinc-700" />
</div> </div>
<div className="relative flex justify-center text-sm"> <div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-neutral-900 text-gray-500 dark:text-gray-400"> <span className="px-2 bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400">
Or continue with Or continue with
</span> </span>
</div> </div>
@@ -151,7 +151,7 @@ export default function LoginPage() {
<button <button
type="button" type="button"
onClick={handleGoogle} onClick={handleGoogle}
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-neutral-700 rounded-md shadow-sm bg-white dark:bg-neutral-800 text-sm font-medium text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition" 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} disabled={loading}
> >
<span className="sr-only">Sign in with Google</span> <span className="sr-only">Sign in with Google</span>

View File

@@ -23,7 +23,7 @@ export default function ProfilePage() {
} }
return ( return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-neutral-900 rounded shadow"> <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> <h1 className="text-2xl font-bold mb-4">Profile</h1>
<div className="space-y-2"> <div className="space-y-2">
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div> <div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>

View File

@@ -44,15 +44,15 @@ export default function RegisterPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-neutral-900"> <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-neutral-900 rounded-lg shadow p-8"> <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> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<input <input
type="email" type="email"
required required
placeholder="Email address" placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500" 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} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
disabled={loading} disabled={loading}
@@ -62,7 +62,7 @@ export default function RegisterPage() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
required required
placeholder="Password" placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10" 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} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
disabled={loading} disabled={loading}

View File

@@ -0,0 +1,7 @@
import { db } from "@/db";
import { brands } from "@/db/schema";
export async function GET() {
const allBrands = await db.select().from(brands);
return Response.json({ success: true, data: allBrands });
}

View File

@@ -0,0 +1,7 @@
import { db } from "@/db";
import { componentType } from "@/db/schema";
export async function GET() {
const allComponentTypes = await db.select().from(componentType);
return Response.json({ success: true, data: allComponentTypes });
}

View File

@@ -0,0 +1,58 @@
import { db } from '@/db';
import { bb_products } from '@/db/schema';
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
export async function GET(
req: Request,
{ params }: { params: { slug: string } }
) {
try {
const allProducts = await db.select().from(bb_products);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(item.productName),
description: item.shortDescription || item.longDescription || '',
longDescription: item.longDescription,
image_url: item.imageUrl || item.thumbUrl || '/window.svg',
images: [item.imageUrl, item.thumbUrl].filter(Boolean),
brand: {
id: item.brandName || 'unknown',
name: item.brandName || 'Unknown',
logo: item.brandLogoImage || '',
},
category: {
id: item.category || 'unknown',
name: item.category || 'Unknown',
},
subcategory: item.subcategory,
offers: [
{
price: parseFloat(item.salePrice || item.retailPrice || '0'),
url: item.buyLink || '',
vendor: {
name: 'Brownells',
logo: '',
},
inStock: true,
shipping: '',
},
],
restrictions: {},
}));
const found = mapped.find((p: any) => p.slug === params.slug);
if (found) {
return Response.json({ success: true, product: found });
} else {
return Response.json({ success: false, error: 'Not found' }, { status: 404 });
}
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { db } from "@/db";
import { bb_products } from "@/db/schema";
export async function GET() {
try {
const allProducts = await db.select().from(bb_products).limit(50);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(item.productName),
...item
}));
return Response.json({ success: true, data: mapped });
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}

58
src/app/globals copy.css Normal file
View File

@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply transition-colors duration-200;
}
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-100 dark:bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-300 dark:bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-400 dark:bg-zinc-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-zinc-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-float {
animation: float 4s ease-in-out infinite;
}

View File

@@ -1,50 +1,3 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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-neutral-100 dark:bg-neutral-800;
}
::-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 focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}

View File

@@ -29,8 +29,7 @@ export default function LandingPage() {
A better way to plan your next build A better way to plan your next build
</h1> </h1>
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8"> <p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet 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.
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
</p> </p>
<div className="mt-10 flex items-top gap-x-6"> <div className="mt-10 flex items-top gap-x-6">
<Link <Link
@@ -42,11 +41,12 @@ export default function LandingPage() {
</div> </div>
</div> </div>
{/* Right: Product Image */} {/* Right: Product Image */}
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center"> <div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center group">
<img <img
alt="AR-15 Lower Receiver" alt="AR-15 Lower Receiver"
src="https://i.imgur.com/IK8FbaI.png" src="https://i.imgur.com/IK8FbaI.png"
className="max-w-md w-full h-auto object-contain rounded-xl" 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> </div>

View File

@@ -0,0 +1,198 @@
// Auto-generated mapping from product feed categories (all_categories.csv)
// to standardized builder component types (component_type.csv)
// Refine as needed for your builder logic
export const categoryToComponentType: Record<string, string> = {
"Sporting Bolt Action Centerfire Rifles": "N/A",
"HANDGUN SIGHTS": "N/A",
"Synthetic Holsters": "N/A",
"Scope Mounts": "Accessories",
"Short Barrel Shotguns": "N/A",
"Polymer Centerfire Conceal Carry Pistols": "N/A",
"LESS LETHAL AMMO": "N/A",
"LESS LETHAL PISTOL": "N/A",
"Rifle/Shotgun Combos": "N/A",
"Leveraction Shotguns": "N/A",
"Specialty Pistols": "N/A",
"Miscellaneous Accessories": "N/A",
"LESS LETHAL ACCESSORIES": "N/A",
"Tactical Rimfire Semi-Auto Rifles": "N/A",
"Sporting Semi-Auto Rimfire Rifles": "N/A",
"Lower Receivers": "Lower Receiver",
"Handgun Magazines": "Magazine",
"Non-Magnified Optic Mounts": "Accessories",
"Magnified Tactical Optics": "Accessories",
"BOLT ACTION SHOTGUN": "N/A",
"Sporting Semi-Auto Shotguns": "N/A",
"Metal Frame Centerfire Pistols": "N/A",
"THERMAL OPTICS": "Accessories",
"Sporting Leveraction Rimfire Rifles": "N/A",
"Pump Rimfire Rifles": "N/A",
"TACTICAL CENTERFIRE SEMI-AUTO PISTOLS": "N/A",
"Single Action Centerfire Revolvers": "N/A",
"Range Finders": "N/A",
"Metal Frame Rimfire Pistols": "N/A",
"Sporting Bolt Action Rimfire Rifles": "N/A",
"Side by Side Shotguns": "N/A",
"Lasers and Lights": "N/A",
"Tactical Pump Shotguns": "N/A",
"Binoculars": "N/A",
"Double Action Centerfire Revolvers": "N/A",
"Tactical Semi-Auto Shotguns": "N/A",
"Scopes": "Accessories",
"Silencer Mounts": "N/A",
"Double Action Centrfire Conceal Revolver": "N/A",
"Sporting Semi-Auto Centerfire Rifles": "N/A",
"FIRE CONTROL UNIT": "N/A",
"Spotting Scopes": "N/A",
"Single Shot Centerfire Rifles": "N/A",
"Derringers": "N/A",
"Pump Centerfire Rifles": "N/A",
"Double Action Rimfire Revolvers": "N/A",
"Tactical Centerfire Semi-Auto Rifles": "N/A",
"Handgun Accessories": "N/A",
"Sporting Leveraction Centerfire Rifles": "N/A",
"Single Shot Shotguns": "N/A",
"Polymer Rimfire Pistols": "N/A",
"LESS LETHAL RIFLE": "N/A",
"Short Barrel Rifles": "N/A",
"Black Powder Guns": "N/A",
"Over/Under Shotguns": "N/A",
"TACTICAL RIMFIRE SEMI-AUTO PISTOL": "N/A",
"Non-Magnified Optic Accessories": "N/A",
"Scope Accessories": "N/A",
"Scope Rings": "N/A",
"Rimfire Silencers": "N/A",
"Non-Magnified Optics": "N/A",
"Metal Frame Centerfire Conceal Pistols": "N/A",
"LONG GUN SIGHTS": "N/A",
"UPPER RECEIVERS": "Upper Receiver",
"Double Action Rimfire Conceal Revolvers": "N/A",
"Rifle Magazines": "Magazine",
"Rifle Accessories": "N/A",
"Silencer Pistons": "N/A",
"Shotgun Silencers": "N/A",
"Tactical Bolt Action Rifles": "N/A",
"Centerfire Ammo": "N/A",
"Single Action Rimfire Revolvers": "N/A",
"Leather Holsters": "N/A",
"AR Style Centerfire Rifles": "N/A",
"Centerfire Pistol Silencers": "N/A",
"Single Shot Rimfire Rifles": "N/A",
"Silencer Accessories": "N/A",
"Sporting Pump Shotguns": "N/A",
"Single Shot Handguns": "N/A",
"Centerfire Rifle Silencers": "N/A",
"Polymer Centerfire Pistols": "N/A",
"Magnified Tactical Optic Mounts": "N/A",
"SHOTGUN MAGAZINES": "Magazine",
"BLACK POWDER FIREARMS (ATF CONTROLLED)": "N/A"
};
// 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"
];
// Hybrid mapping function: prefer subcategory, fallback to category
export function mapToBuilderType(category: string, subcategory: string): string {
if (standardizedComponentTypes.includes(subcategory)) {
return subcategory;
}
if (standardizedComponentTypes.includes(category)) {
return category;
}
return "N/A";
}
// Builder category hierarchy for filters
export const builderCategories = [
{
id: "upper-parts",
name: "Upper Parts",
subcategories: [
{ id: "complete-upper", name: "Complete Upper Receiver" },
{ id: "stripped-upper", name: "Stripped Upper Receiver" },
{ id: "barrel", name: "Barrel" },
{ id: "gas-block", name: "Gas Block" },
{ id: "gas-tube", name: "Gas Tube" },
{ id: "handguard", name: "Handguard / Rail" },
{ id: "bcg", name: "Bolt Carrier Group (BCG)" },
{ id: "charging-handle", name: "Charging Handle" },
{ id: "muzzle-device", name: "Muzzle Device" },
{ id: "forward-assist", name: "Forward Assist" },
{ id: "dust-cover", name: "Dust Cover" }
]
},
{
id: "lower-parts",
name: "Lower Parts",
subcategories: [
{ id: "complete-lower", name: "Complete Lower Receiver" },
{ id: "stripped-lower", name: "Stripped Lower Receiver" },
{ id: "lower-parts-kit", name: "Lower Parts Kit" },
{ id: "trigger", name: "Trigger / Fire Control Group" },
{ id: "buffer-tube", name: "Buffer Tube Assembly" },
{ id: "buffer-spring", name: "Buffer & Spring" },
{ id: "stock", name: "Stock / Brace" },
{ id: "pistol-grip", name: "Pistol Grip" },
{ id: "trigger-guard", name: "Trigger Guard" },
{ id: "ambi-controls", name: "Ambidextrous Controls" }
]
},
{
id: "accessories",
name: "Accessories",
subcategories: [
{ id: "optics", name: "Optics & Sights" },
{ id: "sling-mounts", name: "Sling Mounts / QD Points" },
{ id: "slings", name: "Slings" },
{ id: "grips-bipods", name: "Vertical Grips / Bipods" },
{ id: "lights", name: "Weapon Lights" },
{ id: "magazines", name: "Magazines" },
{ id: "optic-mounts", name: "Optic Mounts / Rings" },
{ id: "suppressors", name: "Suppressors / Adapters" }
]
},
{
id: "kits-bundles",
name: "Kits / Bundles",
subcategories: [
{ id: "rifle-kit", name: "Rifle Kit" },
{ id: "pistol-kit", name: "Pistol Kit" },
{ id: "upper-kit", name: "Upper Build Kit" },
{ id: "lower-kit", name: "Lower Build Kit" },
{ id: "kit-80", name: "80% Build Kit" },
{ id: "receiver-set", name: "Matched Receiver Set" }
]
}
];
// Example subcategory mapping (expand as needed)
export const subcategoryMapping: Record<string, string> = {
"Rifle Barrels": "barrel",
"Bolt Carrier Groups": "bcg",
"Handguards & Rails": "handguard",
"Suppressors": "suppressors",
"Receivers": "complete-upper", // or "stripped-upper" if you want to split
"Triggers": "trigger",
"Rifle Stocks": "stock",
"Buttstocks": "stock",
// ...add more mappings as needed
};

View File

@@ -9,24 +9,45 @@ import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert'; import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import Link from 'next/link'; import Link from 'next/link';
import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
import Image from 'next/image'; import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore'; import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page'; import { buildGroups } from '../build/page';
import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
// Extract unique values for dropdowns // Product type (copied from mock/product for type safety)
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))]; type Product = {
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))]; id: string;
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))]; 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 // Restrictions for filter dropdown
const restrictionOptions = [ const restrictionOptions = [
'All', { value: '', label: 'All Restrictions' },
'NFA', { value: 'NFA', label: 'NFA' },
'SBR', { value: 'SBR', label: 'SBR' },
'Suppressor', { value: 'Suppressor', label: 'Suppressor' },
'State Restrictions', { value: 'State Restrictions', label: 'State Restrictions' },
]; ];
type SortField = 'name' | 'category' | 'price'; type SortField = 'name' | 'category' | 'price';
@@ -104,23 +125,23 @@ const Dropdown = ({
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
options: string[]; options: { value: string; label: string }[];
placeholder?: string; placeholder?: string;
}) => { }) => {
return ( return (
<div className="relative"> <div className="relative">
<Listbox value={value} onChange={onChange}> <Listbox value={value} onChange={onChange}>
<div className="relative"> <div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <Listbox.Label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
{label} {label}
</Listbox.Label> </Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 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"> <Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-zinc-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white"> <span className="block truncate text-zinc-900 dark:text-white">
{value || placeholder} {value || placeholder}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon <ChevronUpDownIcon
className="h-4 w-4 text-neutral-400" className="h-4 w-4 text-zinc-400"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
@@ -130,7 +151,7 @@ const Dropdown = ({
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
className="absolute z-20 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" className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-zinc-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
> >
<Listbox.Options> <Listbox.Options>
{options.map((option, optionIdx) => ( {options.map((option, optionIdx) => (
@@ -138,15 +159,15 @@ const Dropdown = ({
key={optionIdx} key={optionIdx}
className={({ active }) => className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${ `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' active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-zinc-900 dark:text-white'
}` }`
} }
value={option} value={option.value}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option} {option.label}
</span> </span>
{selected ? ( {selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400"> <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
@@ -203,41 +224,19 @@ const getComponentCategory = (productCategory: string): string => {
// Map product categories to specific checklist component names // Map product categories to specific checklist component names
const getMatchingComponentName = (productCategory: string): string => { const getMatchingComponentName = (productCategory: string): string => {
const componentMap: Record<string, string> = { return categoryToComponentType[productCategory] || '';
'Upper Receiver': 'Upper Receiver',
'Barrel': 'Barrel',
'BCG': 'Bolt Carrier Group (BCG)',
'Bolt Carrier Group': 'Bolt Carrier Group (BCG)',
'Charging Handle': 'Charging Handle',
'Gas Block': 'Gas Block',
'Gas Tube': 'Gas Tube',
'Handguard': 'Handguard',
'Muzzle Device': 'Muzzle Device',
'Suppressor': 'Muzzle Device', // Suppressors go to Muzzle Device component
'Lower Receiver': 'Lower Receiver',
'Trigger': 'Trigger',
'Trigger Guard': 'Trigger Guard',
'Pistol Grip': 'Pistol Grip',
'Buffer Tube': 'Buffer Tube',
'Buffer': 'Buffer',
'Buffer Spring': 'Buffer Spring',
'Stock': 'Stock',
'Magazine': 'Magazine',
'Sights': 'Sights',
'Optic': 'Sights',
'Scope': 'Sights',
'Red Dot': 'Sights',
};
return componentMap[productCategory] || '';
}; };
export default function Home() { export default function Home() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [selectedCategory, setSelectedCategory] = useState('All'); 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 [selectedBrand, setSelectedBrand] = useState('All');
const [selectedVendor, setSelectedVendor] = useState('All'); const [selectedVendor, setSelectedVendor] = useState('All');
const [priceRange, setPriceRange] = useState(''); const [priceRange, setPriceRange] = useState('');
@@ -252,17 +251,85 @@ export default function Home() {
const selectedParts = useBuildStore((state) => state.selectedParts); const selectedParts = useBuildStore((state) => state.selectedParts);
const removePartForComponent = useBuildStore((state) => state.removePartForComponent); 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 // Read category from URL parameter on page load
useEffect(() => { useEffect(() => {
const categoryParam = searchParams.get('category'); const categoryParam = searchParams.get('category');
if (categoryParam && categories.includes(categoryParam)) { if (categoryParam && categories.some(c => c.id === categoryParam)) {
setSelectedCategory(categoryParam); setSelectedCategoryId(categoryParam);
} }
}, [searchParams]); // 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 // Filter parts based on selected criteria
const filteredParts = mockProducts.filter(part => { const filteredParts = products.filter(part => {
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory; 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 matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor); const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
const matchesSearch = !searchTerm || const matchesSearch = !searchTerm ||
@@ -270,14 +337,10 @@ export default function Home() {
part.description.toLowerCase().includes(searchTerm.toLowerCase()) || part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase()); part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
// Restriction filter logic // Restriction filter logic (no real data, so always true)
let matchesRestriction = true; let matchesRestriction = true;
if (selectedRestriction) { if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa; matchesRestriction = false;
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 // Price range filtering
@@ -300,13 +363,12 @@ export default function Home() {
} }
} }
return matchesCategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction; return matchesCategory && matchesSubcategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
}); });
// Sort parts // Sort parts
const sortedParts = [...filteredParts].sort((a, b) => { const sortedParts = [...filteredParts].sort((a, b) => {
let aValue: any, bValue: any; let aValue: any, bValue: any;
if (sortField === 'price') { if (sortField === 'price') {
aValue = Math.min(...a.offers.map(offer => offer.price)); aValue = Math.min(...a.offers.map(offer => offer.price));
bValue = Math.min(...b.offers.map(offer => offer.price)); bValue = Math.min(...b.offers.map(offer => offer.price));
@@ -317,7 +379,6 @@ export default function Home() {
aValue = a.name.toLowerCase(); aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase(); bValue = b.name.toLowerCase();
} }
if (sortDirection === 'asc') { if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1; return aValue > bValue ? 1 : -1;
} else { } else {
@@ -342,7 +403,8 @@ export default function Home() {
}; };
const clearFilters = () => { const clearFilters = () => {
setSelectedCategory('All'); setSelectedCategoryId('all');
setSelectedSubcategoryId('all');
setSelectedBrand('All'); setSelectedBrand('All');
setSelectedVendor('All'); setSelectedVendor('All');
setSearchTerm(''); setSearchTerm('');
@@ -350,7 +412,7 @@ export default function Home() {
setSelectedRestriction(''); setSelectedRestriction('');
}; };
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction; const hasActiveFilters = selectedCategoryId !== 'all' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State) // RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
const getRestrictionFlags = (restrictions?: Product['restrictions']) => { const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
@@ -367,22 +429,31 @@ export default function Home() {
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500); setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
}; };
useEffect(() => {
if (products.length) {
const uniqueCategories = Array.from(new Set(products.map(p => p.category?.name)));
const uniqueSubcategories = Array.from(new Set(products.map(p => p.subcategory)));
console.log('Unique categories:', uniqueCategories);
console.log('Unique subcategories:', uniqueSubcategories);
}
}, [products]);
return ( return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900"> <main className="min-h-screen bg-zinc-50 dark:bg-zinc-900">
{/* Page Title */} {/* Page Title */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700"> <div className="bg-white dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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"> <h1 className="text-3xl font-bold text-zinc-900 dark:text-white">
Parts Catalog Parts Catalog
{selectedCategory !== 'All' && ( {selectedCategory && selectedCategoryId !== 'all' && (
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl"> <span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
- {selectedCategory} - {selectedCategory.name}
</span> </span>
)} )}
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2"> <p className="text-zinc-600 dark:text-zinc-400 mt-2">
{selectedCategory !== 'All' {selectedCategory && selectedCategoryId !== 'all'
? `Showing ${selectedCategory} parts for your build` ? `Showing ${selectedCategory.name} parts for your build`
: 'Browse and filter firearm parts for your build' : 'Browse and filter firearm parts for your build'
} }
</p> </p>
@@ -390,7 +461,7 @@ export default function Home() {
</div> </div>
{/* Search and Filters */} {/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700"> <div className="bg-white dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Search Row */} {/* Search Row */}
<div className="mb-3 flex justify-end"> <div className="mb-3 flex justify-end">
@@ -410,7 +481,7 @@ export default function Home() {
setIsSearchExpanded(false); setIsSearchExpanded(false);
setSearchTerm(''); setSearchTerm('');
}} }}
className="p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors flex-shrink-0" className="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors flex-shrink-0"
aria-label="Close search" aria-label="Close search"
> >
<XMarkIcon className="h-5 w-5" /> <XMarkIcon className="h-5 w-5" />
@@ -419,7 +490,7 @@ export default function Home() {
) : ( ) : (
<button <button
onClick={() => setIsSearchExpanded(true)} onClick={() => setIsSearchExpanded(true)}
className="p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700" className="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700"
aria-label="Open search" aria-label="Open search"
> >
<MagnifyingGlassIcon className="h-5 w-5" /> <MagnifyingGlassIcon className="h-5 w-5" />
@@ -434,13 +505,26 @@ export default function Home() {
<div className="col-span-1"> <div className="col-span-1">
<Dropdown <Dropdown
label="Category" label="Category"
value={selectedCategory} value={selectedCategoryId}
onChange={setSelectedCategory} onChange={setSelectedCategoryId}
options={categories} options={categories.map(c => ({ value: c.id, label: c.name }))}
placeholder="All categories" placeholder="All categories"
/> />
</div> </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 */} {/* Brand Dropdown */}
<div className="col-span-1"> <div className="col-span-1">
<Dropdown <Dropdown
@@ -467,11 +551,11 @@ export default function Home() {
<div className="col-span-1"> <div className="col-span-1">
<Listbox value={priceRange} onChange={setPriceRange}> <Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative"> <div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <Listbox.Label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Price Range Price Range
</Listbox.Label> </Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 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"> <Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-zinc-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white"> <span className="block truncate text-zinc-900 dark:text-white">
{priceRange === '' ? 'Select price range' : {priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' : priceRange === 'under-100' ? 'Under $100' :
priceRange === '100-300' ? '$100 - $300' : priceRange === '100-300' ? '$100 - $300' :
@@ -480,7 +564,7 @@ export default function Home() {
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon <ChevronUpDownIcon
className="h-4 w-4 text-neutral-400" className="h-4 w-4 text-zinc-400"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
@@ -490,7 +574,7 @@ export default function Home() {
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
className="absolute z-20 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" className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-zinc-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
> >
<Listbox.Options> <Listbox.Options>
{[ {[
@@ -504,7 +588,7 @@ export default function Home() {
key={optionIdx} key={optionIdx}
className={({ active }) => className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${ `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' active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-zinc-900 dark:text-white'
}` }`
} }
value={option.value} value={option.value}
@@ -548,7 +632,7 @@ export default function Home() {
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${ className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
hasActiveFilters hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white' ? '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' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-400 dark:text-zinc-500 cursor-not-allowed'
}`} }`}
> >
<XMarkIcon className="h-3.5 w-3.5" /> <XMarkIcon className="h-3.5 w-3.5" />
@@ -563,18 +647,19 @@ export default function Home() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* View Toggle and Results Count */} {/* View Toggle and Results Count */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div className="text-sm text-neutral-700 dark:text-neutral-300"> <div className="text-sm text-zinc-700 dark:text-zinc-300">
Showing {sortedParts.length} of {mockProducts.length} parts {loading ? 'Loading...' : `Showing ${sortedParts.length} of ${products.length} parts`}
{hasActiveFilters && ( {hasActiveFilters && !loading && (
<span className="ml-2 text-primary-600 dark:text-primary-400"> <span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered) (filtered)
</span> </span>
)} )}
{error && <span className="ml-2 text-red-500">{error}</span>}
</div> </div>
{/* View Toggle */} {/* View Toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span> <span className="text-sm text-zinc-600 dark:text-zinc-400">View:</span>
<div className="btn-group"> <div className="btn-group">
<button <button
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`} className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
@@ -596,19 +681,19 @@ export default function Home() {
{/* Table View */} {/* Table View */}
{viewMode === 'table' && ( {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="bg-white dark:bg-zinc-800 shadow-sm rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
<div className="overflow-x-auto max-h-screen overflow-y-auto"> <div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700"> <table className="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm"> <thead className="bg-zinc-50 dark:bg-zinc-700 sticky top-0 z-10 shadow-sm">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-300 uppercase tracking-wider">
Product Product
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-300 uppercase tracking-wider">
Category Category
</th> </th>
<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" className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-300 uppercase tracking-wider cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-600"
onClick={() => handleSort('price')} onClick={() => handleSort('price')}
> >
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
@@ -616,23 +701,22 @@ export default function Home() {
<span className="text-sm">{getSortIcon('price')}</span> <span className="text-sm">{getSortIcon('price')}</span>
</div> </div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-300 uppercase tracking-wider">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700"> <tbody className="bg-white dark:bg-zinc-800 divide-y divide-zinc-200 dark:divide-zinc-700">
{sortedParts.map((part) => ( {sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"> <tr key={part.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]"> <td className="px-0 py-2 flex items-center gap-2 align-top">
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700"> <div className="w-12 h-12 flex-shrink-0 rounded bg-zinc-100 dark:bg-zinc-700 overflow-hidden flex items-center justify-center border border-zinc-200 dark:border-zinc-700">
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" /> <Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
</div> </div>
<div> <div className="max-w-md break-words whitespace-normal">
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400"> <Link href={`/products/${part.slug}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
{part.name} {part.name}
</Link> </Link>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
@@ -641,7 +725,7 @@ export default function Home() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-neutral-900 dark:text-white"> <div className="text-sm font-semibold text-zinc-900 dark:text-white">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)} ${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div> </div>
</td> </td>
@@ -687,7 +771,7 @@ export default function Home() {
); );
} else { } else {
return ( return (
<span className="text-xs text-gray-400">Part Selected</span> <span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-200 text-gray-500 text-xs">N/A</span>
); );
} }
})()} })()}
@@ -699,19 +783,16 @@ export default function Home() {
</div> </div>
{/* Table Footer */} {/* 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="bg-zinc-50 dark:bg-zinc-700 px-6 py-3 border-t border-zinc-200 dark:border-zinc-600">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-neutral-700 dark:text-neutral-300"> <div className="text-sm text-zinc-700 dark:text-zinc-300">
Showing {sortedParts.length} of {mockProducts.length} parts Showing {sortedParts.length} of {products.length} parts
{hasActiveFilters && ( {hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400"> <span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered) (filtered)
</span> </span>
)} )}
</div> </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> </div>
</div> </div>
@@ -728,8 +809,8 @@ export default function Home() {
</div> </div>
{/* Compact Restriction Legend */} {/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <div className="mt-8 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400"> <div className="flex items-center justify-center gap-4 text-xs text-zinc-500 dark:text-zinc-400">
<span className="font-medium">Restrictions:</span> <span className="font-medium">Restrictions:</span>
<div className="flex items-center gap-1"> <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> <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>

View File

@@ -1,23 +1,77 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { mockProducts } from '@/mock/product';
import RestrictionAlert from '@/components/RestrictionAlert'; import RestrictionAlert from '@/components/RestrictionAlert';
import { StarIcon } from '@heroicons/react/20/solid'; import { StarIcon } from '@heroicons/react/20/solid';
import Image from 'next/image'; import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore'; import { useBuildStore } from '@/store/useBuildStore';
// 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() { export default function ProductDetailsPage() {
const params = useParams(); const params = useParams();
const productId = params.id as string; const slug = params.slug as string;
const [product, setProduct] = useState<Product | null>(null);
const product = mockProducts.find(p => p.id === productId); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [selectedOffer, setSelectedOffer] = useState(0); const [selectedOffer, setSelectedOffer] = useState(0);
const [addSuccess, setAddSuccess] = useState(false); const [addSuccess, setAddSuccess] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent); 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) { if (!product) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -28,19 +82,15 @@ export default function ProductDetailsPage() {
); );
} }
// Use images array if present, otherwise fallback to image_url
const allImages = product.images && product.images.length > 0 const allImages = product.images && product.images.length > 0
? product.images ? product.images
: [product.image_url]; : [product.image_url];
const lowestPrice = Math.min(...product.offers.map(o => o.price)); const lowestPrice = Math.min(...product.offers.map(o => o.price));
const highestPrice = Math.max(...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 = () => { const handleAddToBuild = () => {
// Map category to component ID // Map category to component ID (can be improved to match /parts logic)
const categoryToComponentMap: Record<string, string> = { const categoryToComponentMap = {
'Barrel': 'barrel', 'Barrel': 'barrel',
'Upper Receiver': 'upper', 'Upper Receiver': 'upper',
'Suppressor': 'suppressor', 'Suppressor': 'suppressor',
@@ -60,9 +110,7 @@ export default function ProductDetailsPage() {
'Magazine': 'magazine', 'Magazine': 'magazine',
'Sights': 'sights' 'Sights': 'sights'
}; };
const componentId = (categoryToComponentMap as Record<string, string>)[product.category.name] || product.category.id;
const componentId = categoryToComponentMap[product.category.name] || product.category.id;
selectPartForComponent(componentId, { selectPartForComponent(componentId, {
id: product.id, id: product.id,
name: product.name, name: product.name,
@@ -100,7 +148,7 @@ export default function ProductDetailsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Product Images */} {/* Product Images */}
<div className="space-y-4"> <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 <Image
src={allImages[selectedImageIndex]} src={allImages[selectedImageIndex]}
alt={product.name} alt={product.name}
@@ -119,7 +167,7 @@ export default function ProductDetailsPage() {
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${ className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${
selectedImageIndex === index selectedImageIndex === index
? 'border-primary-500' ? 'border-primary-500'
: 'border-neutral-200 dark:border-neutral-700' : 'border-zinc-200 dark:border-zinc-700'
}`} }`}
> >
<Image <Image
@@ -149,17 +197,17 @@ export default function ProductDetailsPage() {
/> />
)} )}
<div> <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} {product.brand.name}
</div> </div>
<div className="text-sm text-neutral-600 dark:text-neutral-400"> <div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.category.name} {product.category.name}
</div> </div>
</div> </div>
</div> </div>
{/* Product Name */} {/* 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} {product.name}
</h1> </h1>
@@ -169,40 +217,19 @@ export default function ProductDetailsPage() {
${lowestPrice.toFixed(2)} ${lowestPrice.toFixed(2)}
</div> </div>
{lowestPrice !== highestPrice && ( {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)} - ${highestPrice.toFixed(2)}
</div> </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' : ''} from {product.offers.length} vendor{product.offers.length > 1 ? 's' : ''}
</div> </div>
</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 */} {/* Description */}
<div> <div>
<h3 className="text-lg font-semibold mb-2">Description</h3> <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} {product.longDescription || product.description}
</p> </p>
</div> </div>
@@ -222,54 +249,6 @@ export default function ProductDetailsPage() {
</div> </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 */} {/* Vendor Offers */}
<div className="mt-12"> <div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Where to Buy</h2> <h2 className="text-2xl font-bold mb-6">Where to Buy</h2>
@@ -291,7 +270,7 @@ export default function ProductDetailsPage() {
<div> <div>
<div className="font-semibold">{offer.vendor.name}</div> <div className="font-semibold">{offer.vendor.name}</div>
{offer.shipping && ( {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} {offer.shipping}
</div> </div>
)} )}
@@ -323,52 +302,6 @@ export default function ProductDetailsPage() {
))} ))}
</div> </div>
</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> </div>
); );
} }

View File

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

View File

@@ -10,7 +10,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<SessionProvider> <SessionProvider>
<AuthProvider> <AuthProvider>
<ThemeProvider> <ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-900 transition-colors duration-200">
<NavigationWrapper /> <NavigationWrapper />
{children} {children}
</div> </div>

6
src/db/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });

558
src/db/schema.ts Normal file
View File

@@ -0,0 +1,558 @@
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("users",
{
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 sessions = pgTable(
"sessions",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 21 }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
},
(t) => ({
userIdx: index("session_user_idx").on(t.userId),
}),
);
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
// });

40
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,40 @@
const constants = {
APP_NAME: 'Ballistic Builder',
SITE_NAME: 'Ballistic Builder',
COMPANY_NAME: 'Forward Group, LLC',
COMPANY_URL: 'https://goforward.group',
AUTHOR: 'Forward Group, LLC',
META_KEYWORDS: 'Pew Pew',
META_DESCRIPTION: 'Pow Pow',
DESCRIPTION: 'Developed by Forward Group, LLC',
PJAM_RAINIER: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8713',
PJAM_BARRETTA: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8342'
};
export default constants;
export enum SITE_CONT_TYPE {
CONTACTUS = "CONTACTUS",
PRIVACYPOLICY = "PP",
PERSONALINFOPOLICY = "PIP",
FAQ = "FAQ",
TERMSOFSERVICE = "TOS",
ABOUTUS="ABOUTUS",
DISCLOSURE="DISCLOSURE"
}
export const APP_TITLE = "Ballistics Builder";
export const DATABASE_PREFIX = "";
export const TEST_DB_PREFIX = "test_acme";
export const EMAIL_SENDER = '"Ballistics Builder" <don@goforward.group>';
export enum Paths {
Home = "/",
Login = "/login",
Signup = "/signup",
Dashboard = "/dashboard",
VerifyEmail = "/verify-email",
ResetPassword = "/reset-password",
}

View File

@@ -1,120 +1,8 @@
console.log('DaisyUI plugin loaded');
/** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', "./src/**/*.{js,ts,jsx,tsx,mdx}",
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', "./app/**/*.{js,ts,jsx,tsx,mdx}",
'./src/app/**/*.{js,ts,jsx,tsx,mdx}', ],
], theme: { extend: {} },
darkMode: 'class', plugins: [],
theme: { }
extend: {
colors: {
// Secondary accent colors
accent: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
// Neutral grays with warm undertones
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
// Success colors
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// Warning colors
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
{
pew: {
primary: '#4B6516', // Olive/army green
'primary-content': '#fff',
accent: '#181C20', // Dark navy for CTA/footer
'accent-content': '#fff',
neutral: '#222',
'base-100': '#fff',
'base-200': '#f5f6fa',
'base-300': '#e5e7eb',
info: '#3ABFF8',
success: '#36D399',
warning: '#FBBD23',
error: '#F87272',
},
},
'dark',
],
darkTheme: "dark",
base: true,
styled: true,
utils: true,
logs: false,
rtl: false,
prefix: '',
// 'pew' is the default theme
},
};