4 Commits

Author SHA1 Message Date
sean 41e55404bf nextauth working. 2025-06-30 06:36:03 -04:00
sean ccc6e41724 add state management and other stuff 2025-06-29 15:58:03 -04:00
sean 64f288d8f7 styles all fixed 2025-06-29 14:07:26 -04:00
sean 14b25e7359 fixed theming and color 2025-06-29 13:43:46 -04:00
28 changed files with 3254 additions and 1682 deletions
+1 -8
View File
@@ -2,14 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'placehold.co',
port: '',
pathname: '/**',
},
],
remotePatterns: [],
},
};
+270 -3
View File
@@ -8,12 +8,15 @@
"name": "pew-builder-nextjs",
"version": "0.1.0",
"dependencies": {
"@auth/core": "^0.34.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"daisyui": "^4.7.3",
"next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -41,6 +44,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
"integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -1048,6 +1091,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1223,6 +1275,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1258,7 +1316,7 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -2504,6 +2562,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2545,7 +2612,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/culori": {
@@ -4448,6 +4515,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4803,6 +4879,56 @@
}
}
},
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-auth/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4858,6 +4984,21 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4991,6 +5132,60 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5284,6 +5479,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5294,6 +5511,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6591,6 +6814,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6801,6 +7033,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
@@ -6826,6 +7064,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+4 -1
View File
@@ -9,12 +9,15 @@
"lint": "next lint"
},
"dependencies": {
"@auth/core": "^0.34.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"daisyui": "^4.7.3",
"next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
+46
View File
@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitted(true);
}
return (
<div className="flex flex-1 items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/>
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-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"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={submitted}
/>
<button
type="submit"
className="w-full btn btn-primary"
disabled={submitted}
>
{submitted ? 'Check your email' : 'Send reset link'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Back to login</Link>
</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from 'next/link';
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
{/* Simple navbar with back button */}
<nav className="bg-white dark:bg-neutral-900 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-start h-16">
<div className="flex items-center">
<Link
href="/"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100 focus:outline-none transition"
>
<svg
className="h-5 w-5 mr-1"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
</Link>
</div>
</div>
</div>
</nav>
{/* Main content */}
<main className="flex-1">
{children}
</main>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: searchParams.get('callbackUrl') || '/',
});
setLoading(false);
if (res?.error) {
setError('Invalid email or password');
} else if (res?.ok) {
router.push(res.url || '/');
}
}
async function handleGoogle() {
setLoading(true);
await signIn('google', { callbackUrl: searchParams.get('callbackUrl') || '/' });
setLoading(false);
}
return (
<div className="min-h-screen flex">
{/* Left side image or illustration */}
<div className="hidden lg:block relative w-0 flex-1 bg-gray-900">
{/* You can replace this with your own image or illustration */}
<img
className="absolute inset-0 h-full w-full object-cover opacity-80"
src="/window.svg"
alt="Login visual"
/>
</div>
{/* Right side form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-neutral-900 min-h-screen">
<div className="mx-auto w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Or{' '}
<Link href="/account/register" className="font-medium text-primary-600 hover:text-primary-500">
Sign Up For Free
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="remember" value="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-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"
placeholder="Email address"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-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"
placeholder="Password"
disabled={loading}
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-2">{error}</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="checkbox checkbox-primary"
disabled={loading}
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/account/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
{/* Social login buttons */}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-neutral-700" />
</div>
<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">
Or continue with
</span>
</div>
</div>
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={handleGoogle}
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-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"
disabled={loading}
>
<span className="sr-only">Sign in with Google</span>
{/* Google Icon Placeholder */}
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M21.35 11.1H12v2.8h5.35c-.23 1.2-1.4 3.5-5.35 3.5-3.22 0-5.85-2.67-5.85-5.9s2.63-5.9 5.85-5.9c1.83 0 3.06.78 3.76 1.44l2.57-2.5C17.09 3.59 14.77 2.5 12 2.5 6.75 2.5 2.5 6.75 2.5 12s4.25 9.5 9.5 9.5c5.47 0 9.09-3.84 9.09-9.25 0-.62-.07-1.08-.16-1.55z" /></svg>
<span className="ml-2">Sign in with Google</span>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'unauthenticated') {
router.replace('/account/login');
}
}, [status, router]);
if (status === 'loading') {
return <div className="flex justify-center items-center h-64">Loading...</div>;
}
if (!session?.user) {
return null;
}
return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-neutral-900 rounded shadow">
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<div className="space-y-2">
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>
<div><span className="font-semibold">Email:</span> {session.user.email}</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from 'next-auth/react';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setLoading(false);
setError(data.error || 'Registration failed');
return;
}
// Auto-login after registration
const signInRes = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: '/account/profile',
});
setLoading(false);
if (signInRes?.ok) {
router.push('/');
} else {
router.push('/account/login?registered=1');
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-neutral-900">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-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"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={loading}
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 dark:border-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"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="absolute inset-y-0 right-2 flex items-center text-xs text-gray-500 dark:text-gray-300"
tabIndex={-1}
onClick={() => setShowPassword(v => !v)}
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{error && <div className="text-red-600 text-sm">{error}</div>}
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Already have an account? Sign in</Link>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
// Check in-memory user store
const user = users.find(
(u) => u.email === credentials.email && u.password === credentials.password
);
if (user) {
return {
id: user.email,
email: user.email,
name: user.email.split('@')[0],
};
}
// For demo, still allow the test user
if (credentials.email === "test@example.com" && credentials.password === "password") {
return {
id: "1",
email: credentials.email,
name: "Test User",
};
}
return null;
}
}),
],
pages: {
signIn: '/account/login',
// signUp: '/account/register', // Uncomment when register page is ready
// error: '/account/error', // Uncomment when error page is ready
},
callbacks: {
async session({ session, token }) {
// Add any additional user data to the session here
return session;
},
async jwt({ token, user }) {
// Add any additional user data to the JWT here
if (user) {
token.id = user.id;
}
return token;
},
},
})
export { handler as GET, handler as POST }
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
}
// Check if user already exists
const existing = users.find((u) => u.email === email);
if (existing) {
return NextResponse.json({ error: 'User already exists.' }, { status: 400 });
}
// Add new user
users.push({ email, password });
return NextResponse.json({ success: true });
}
+312 -111
View File
@@ -4,6 +4,10 @@ import { useState } from 'react';
import Link from 'next/link';
import React from 'react';
import SearchInput from '@/components/SearchInput';
import RestrictionAlert from '@/components/RestrictionAlert';
import { useBuildStore } from '@/store/useBuildStore';
import { mockProducts } from '@/mock/product';
import { Dialog } from '@headlessui/react';
// AR-15 Build Requirements grouped by main categories
const buildGroups = [
@@ -215,12 +219,60 @@ const categories = ["All", "Upper", "Lower", "Accessory"];
type SortField = 'name' | 'category' | 'estimatedPrice' | 'status';
type SortDirection = 'asc' | 'desc';
// Map checklist component categories to product categories for filtering
const getProductCategory = (componentCategory: string): string => {
const categoryMap: Record<string, string> = {
'Upper': 'Upper Receiver', // Default to Upper Receiver for Upper category
'Lower': 'Lower Receiver', // Default to Lower Receiver for Lower category
'Accessory': 'Magazine', // Default to Magazine for Accessory category
};
return categoryMap[componentCategory] || 'Magazine';
};
// Map specific checklist components to product categories
const getProductCategoryForComponent = (componentName: string): string => {
const componentMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper Receiver',
'Barrel': 'Barrel',
'Bolt Carrier Group (BCG)': 'BCG',
'Charging Handle': 'Charging Handle',
'Gas Block': 'Gas Block',
'Gas Tube': 'Gas Tube',
'Handguard': 'Handguard',
'Muzzle Device': 'Muzzle Device',
// Lower components
'Lower Receiver': 'Lower Receiver',
'Trigger': 'Trigger',
'Trigger Guard': 'Lower Receiver',
'Pistol Grip': 'Lower Receiver',
'Buffer Tube': 'Lower Receiver',
'Buffer': 'Lower Receiver',
'Buffer Spring': 'Lower Receiver',
'Stock': 'Stock',
// Accessories
'Magazine': 'Magazine',
'Sights': 'Magazine',
};
return componentMap[componentName] || 'Lower Receiver';
};
export { buildGroups };
export default function BuildPage() {
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [selectedCategory, setSelectedCategory] = useState('All');
const [searchTerm, setSearchTerm] = useState('');
const selectedParts = useBuildStore((state) => state.selectedParts);
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
const clearBuild = useBuildStore((state) => state.clearBuild);
const [showClearModal, setShowClearModal] = useState(false);
// Filter components
const filteredComponents = allComponents.filter(component => {
if (selectedCategory !== 'All' && component.category !== selectedCategory) {
@@ -278,36 +330,66 @@ export default function BuildPage() {
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
case 'completed': return 'bg-green-100 text-green-800';
case 'in-progress': return 'bg-yellow-100 text-yellow-800';
case 'pending': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const totalEstimatedCost = sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0);
const completedCount = sortedComponents.filter(component => component.status === 'completed').length;
const completedCount = sortedComponents.filter(component => selectedParts[component.id]).length;
const actualTotalCost = sortedComponents.reduce((sum, component) => {
const selected = selectedParts[component.id];
if (selected && selected.offers) {
return sum + Math.min(...selected.offers.map(offer => offer.price));
}
return sum;
}, 0);
const hasActiveFilters = selectedCategory !== 'All' || searchTerm;
// Check for restricted parts in the build
const getRestrictedParts = () => {
const restrictedParts: Array<{ part: any; restriction: string }> = [];
Object.values(selectedParts).forEach(selectedPart => {
if (selectedPart) {
const product = mockProducts.find(p => p.id === selectedPart.id);
if (product?.restrictions) {
const restrictions = product.restrictions;
if (restrictions.nfa) restrictedParts.push({ part: product, restriction: 'NFA' });
if (restrictions.sbr) restrictedParts.push({ part: product, restriction: 'SBR' });
if (restrictions.suppressor) restrictedParts.push({ part: product, restriction: 'Suppressor' });
if (restrictions.stateRestrictions && restrictions.stateRestrictions.length > 0) {
restrictedParts.push({ part: product, restriction: 'State Restrictions' });
}
}
}
});
return restrictedParts;
};
const restrictedParts = getRestrictedParts();
const hasNFAItems = restrictedParts.some(rp => rp.restriction === 'NFA');
const hasSuppressors = restrictedParts.some(rp => rp.restriction === 'Suppressor');
const hasStateRestrictions = restrictedParts.some(rp => rp.restriction === 'State Restrictions');
const [showRestrictionAlerts, setShowRestrictionAlerts] = useState(true);
return (
<main className="min-h-screen bg-gray-50">
{/* Page Title */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">AR-15 Build Checklist</h1>
<p className="text-gray-600 mt-2">Track your build progress and find required components</p>
<h1 className="text-3xl font-bold text-gray-900">Plan Your Build</h1>
</div>
</div>
{/* Build Summary */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{allComponents.length}</div>
<div className="text-sm text-gray-500">Total Components</div>
@@ -320,38 +402,138 @@ export default function BuildPage() {
<div className="text-2xl font-bold text-yellow-600">{allComponents.length - completedCount}</div>
<div className="text-sm text-gray-500">Remaining</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">${totalEstimatedCost}</div>
<div className="text-sm text-gray-500">Estimated Cost</div>
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
<div>
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Cost</div>
</div>
<button
className="btn btn-outline btn-error ml-0 md:ml-4"
onClick={() => setShowClearModal(true)}
>
Clear Build
</button>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Search Row */}
<div className="mb-4 flex justify-end">
<div className="w-1/2">
<SearchInput
label="Search Components"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search components..."
/>
{/* Clear Build Modal */}
<Dialog open={showClearModal} onClose={() => setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-black opacity-30" aria-hidden="true" />
<div className="relative bg-white rounded-lg max-w-sm w-full mx-auto p-6 z-10 shadow-xl">
<Dialog.Title className="text-lg font-bold mb-2">Clear Entire Build?</Dialog.Title>
<Dialog.Description className="mb-4 text-gray-600">
Are you sure you want to clear your entire build? This action cannot be undone.
</Dialog.Description>
<div className="flex justify-end gap-2">
<button
className="btn btn-sm btn-ghost"
onClick={() => setShowClearModal(false)}
>
Cancel
</button>
<button
className="btn btn-sm btn-error"
onClick={() => {
clearBuild();
setShowClearModal(false);
}}
>
Yes, Clear Build
</button>
</div>
</div>
</div>
</Dialog>
{/* Restriction Alerts */}
{restrictedParts.length > 0 && (
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<span className="text-sm font-medium text-gray-700">
{restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected
</span>
</div>
<button
onClick={() => setShowRestrictionAlerts(!showRestrictionAlerts)}
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
{showRestrictionAlerts ? 'Hide' : 'Show'} details
<svg
className={`w-4 h-4 transition-transform ${showRestrictionAlerts ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{showRestrictionAlerts && (
<div className="space-y-2">
{hasNFAItems && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🔒</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">NFA Items in Your Build</div>
<div className="text-xs text-yellow-700 mt-1">
Your build contains items that require National Firearms Act registration.
</div>
</div>
</div>
</div>
)}
{hasSuppressors && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🔇</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">Suppressor in Your Build</div>
<div className="text-xs text-yellow-700 mt-1">
Sound suppressor requires NFA registration. Processing times: 6-12 months.
</div>
</div>
</div>
</div>
)}
{hasStateRestrictions && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm">🗺</span>
<div className="flex-1">
<div className="text-sm font-medium text-yellow-800">State Restrictions Apply</div>
<div className="text-xs text-yellow-700 mt-1">
Some items may be restricted in certain states. Verify local laws.
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Search and Filters */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Category Dropdown */}
<div>
<div className="col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
>
{categories.map((category) => (
<option key={category} value={category}>
@@ -362,9 +544,9 @@ export default function BuildPage() {
</div>
{/* Status Filter */}
<div>
<div className="col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<select className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm">
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="in-progress">In Progress</option>
@@ -373,12 +555,12 @@ export default function BuildPage() {
</div>
{/* Sort by */}
<div>
<div className="col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
<select
value={sortField}
onChange={(e) => handleSort(e.target.value as SortField)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm"
>
<option value="name">Name</option>
<option value="category">Category</option>
@@ -388,8 +570,8 @@ export default function BuildPage() {
</div>
{/* Clear Filters */}
<div className="flex items-end">
<button className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<div className="col-span-1 flex items-end">
<button className="w-full px-3 py-1.5 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm">
Clear Filters
</button>
</div>
@@ -399,10 +581,10 @@ export default function BuildPage() {
{/* Build Components Table */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
@@ -425,15 +607,12 @@ export default function BuildPage() {
<span className="text-sm">{getSortIcon('category')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('estimatedPrice')}
>
<div className="flex items-center space-x-1">
<span>Est. Price</span>
<span>Price</span>
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
</div>
</th>
@@ -441,7 +620,7 @@ export default function BuildPage() {
Notes
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
Selected Product
</th>
</tr>
</thead>
@@ -458,81 +637,103 @@ export default function BuildPage() {
return (
<React.Fragment key={group.name}>
{/* Group Header */}
<tr className="bg-gray-800">
<td colSpan={7} className="px-6 py-4">
<tr className="bg-gray-100">
<td colSpan={7} className="px-6 py-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<span className="text-white font-semibold text-lg">
{group.name === 'Upper Parts' ? '🔫' :
group.name === 'Lower Parts' ? '🔧' : '📦'}
</span>
</div>
</div>
<div className="ml-4">
<h3 className="text-xl font-bold text-white">{group.name}</h3>
<p className="text-gray-300">{group.description}</p>
<div>
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
</div>
<div className="ml-auto text-right">
<div className="text-gray-300 font-medium">
<div className="text-xs text-gray-500 font-medium">
{groupComponents.length} components
</div>
</div>
</div>
</td>
</tr>
{/* Group Components */}
{groupComponents.map((component) => (
<tr key={component.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
{component.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-xs text-gray-500">
{component.required ? 'Required' : 'Optional'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{component.category}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.description}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
${component.estimatedPrice}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.notes}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<Link
href={`/?category=${encodeURIComponent(component.category)}`}
className="bg-blue-600 text-white py-1 px-3 rounded text-xs hover:bg-blue-700 transition-colors"
>
Find Parts
</Link>
<button className="bg-gray-100 text-gray-700 py-1 px-2 rounded text-xs hover:bg-gray-200 transition-colors">
</button>
</div>
</td>
</tr>
))}
{groupComponents.map((component) => {
const selected = selectedParts[component.id];
return (
<tr key={component.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
{selected ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Selected
</span>
) : (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(component.status)}`}>
{component.status}
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{selected ? (
<div>
<div className="text-sm font-medium text-gray-900">
<Link
href={`/products/${selected.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{selected.name}
</Link>
</div>
<div className="text-xs text-gray-500">
{selected.brand.name} &middot; {component.required ? 'Required' : 'Optional'}
</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-xs text-gray-500">
{component.required ? 'Required' : 'Optional'}
</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getProductCategoryForComponent(component.name)}
</span>
</td>
<td className="px-6 py-4">
{selected ? (
<div className="text-sm font-semibold text-gray-900">
${Math.min(...selected.offers?.map(offer => offer.price) || [0]).toFixed(2)}
</div>
) : (
<div className="text-sm text-gray-400">
</div>
)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.notes}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{selected ? (
<button
className="btn btn-outline btn-sm"
onClick={() => removePartForComponent(component.id)}
>
Remove
</button>
) : (
<Link
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
className="btn btn-primary btn-sm"
>
Find Parts
</Link>
)}
</td>
</tr>
);
})}
</React.Fragment>
);
})
@@ -562,7 +763,7 @@ export default function BuildPage() {
)}
</div>
<div className="text-sm text-gray-500">
Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)}
Total Value: ${actualTotalCost.toFixed(2)}
</div>
</div>
</div>
+21 -21
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import SearchInput from '@/components/SearchInput';
// Sample build data
const sampleBuilds = [
const sampleMyBuilds = [
{
id: '1',
name: 'Budget AR-15 Build',
@@ -121,18 +121,18 @@ const sampleBuilds = [
}
];
type BuildStatus = 'completed' | 'in-progress' | 'planning';
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc';
export default function BuildsPage() {
export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<BuildStatus | 'all'>('all');
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Filter builds
const filteredBuilds = sampleBuilds.filter(build => {
const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false;
}
@@ -146,7 +146,7 @@ export default function BuildsPage() {
});
// Sort builds
const sortedBuilds = [...filteredBuilds].sort((a, b) => {
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'totalCost') {
@@ -170,7 +170,7 @@ export default function BuildsPage() {
}
});
const getStatusColor = (status: BuildStatus) => {
const getStatusColor = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
@@ -183,7 +183,7 @@ export default function BuildsPage() {
}
};
const getStatusIcon = (status: BuildStatus) => {
const getStatusIcon = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return '✓';
@@ -204,14 +204,14 @@ export default function BuildsPage() {
});
};
const getProgressPercentage = (build: typeof sampleBuilds[0]) => {
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100);
};
const totalBuilds = sampleBuilds.length;
const completedBuilds = sampleBuilds.filter(build => build.status === 'completed').length;
const inProgressBuilds = sampleBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleBuilds.reduce((sum, build) => sum + build.totalCost, 0);
const totalMyBuilds = sampleMyBuilds.length;
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
return (
<main className="min-h-screen bg-gray-50">
@@ -228,15 +228,15 @@ export default function BuildsPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{totalBuilds}</div>
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedBuilds}</div>
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressBuilds}</div>
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div>
</div>
<div className="text-center">
@@ -254,10 +254,10 @@ export default function BuildsPage() {
<div className="mb-4 flex justify-end">
<div className="w-1/2">
<SearchInput
label="Search Builds"
label="Search My Builds"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search builds..."
placeholder="Search my builds..."
/>
</div>
</div>
@@ -269,7 +269,7 @@ export default function BuildsPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as BuildStatus | 'all')}
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Status</option>
@@ -319,9 +319,9 @@ export default function BuildsPage() {
{/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedBuilds.length > 0 ? (
{sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedBuilds.map((build) => (
{sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */}
<div className="h-48 bg-gray-200 relative">
+3 -9
View File
@@ -32,7 +32,7 @@
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
}
/* Card styles */
@@ -41,16 +41,10 @@
}
/* Button styles */
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-secondary {
@apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
/* 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-500 focus:border-primary-500 transition-colors duration-200;
@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;
}
}
+5 -9
View File
@@ -1,8 +1,7 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Navbar from "@/components/Navbar";
import { ThemeProvider } from "@/components/ThemeProvider";
import Providers from "@/components/Providers";
const inter = Inter({ subsets: ["latin"] });
@@ -17,14 +16,11 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning data-theme="pew">
<body className={`${inter.className} antialiased`}>
<ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<Navbar />
{children}
</div>
</ThemeProvider>
<Providers>
{children}
</Providers>
</body>
</html>
);
+417
View File
@@ -0,0 +1,417 @@
'use client';
import { useState } from 'react';
import SearchInput from '@/components/SearchInput';
// Sample build data
const sampleMyBuilds = [
{
id: '1',
name: 'Budget AR-15 Build',
description: 'A cost-effective AR-15 build using quality budget components',
status: 'completed' as const,
totalCost: 847.50,
completedDate: '2024-01-15',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Budget', '5.56 NATO', '16" Barrel'],
image: 'https://picsum.photos/400/250?random=1'
},
{
id: '2',
name: 'Precision Long Range',
description: 'High-end precision build optimized for long-range accuracy',
status: 'in-progress' as const,
totalCost: 2847.99,
startedDate: '2024-02-01',
components: {
total: 18,
completed: 12,
categories: {
'Upper': 6,
'Lower': 4,
'Accessory': 2
}
},
tags: ['Precision', '6.5 Creedmoor', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=2'
},
{
id: '3',
name: 'Home Defense Setup',
description: 'Compact AR-15 configured for home defense scenarios',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-03-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Home Defense', '5.56 NATO', '10.5" Barrel'],
image: 'https://picsum.photos/400/250?random=3'
},
{
id: '4',
name: 'Competition Rifle',
description: 'Lightweight competition build for 3-gun matches',
status: 'completed' as const,
totalCost: 1650.75,
completedDate: '2023-12-10',
components: {
total: 18,
completed: 18,
categories: {
'Upper': 8,
'Lower': 7,
'Accessory': 3
}
},
tags: ['Competition', '5.56 NATO', '18" Barrel'],
image: 'https://picsum.photos/400/250?random=4'
},
{
id: '5',
name: 'Suppressed SBR',
description: 'Short-barreled rifle build with suppressor integration',
status: 'in-progress' as const,
totalCost: 1895.25,
startedDate: '2024-01-20',
components: {
total: 18,
completed: 8,
categories: {
'Upper': 4,
'Lower': 3,
'Accessory': 1
}
},
tags: ['SBR', 'Suppressed', '300 BLK'],
image: 'https://picsum.photos/400/250?random=5'
},
{
id: '6',
name: 'Retro M16A1 Clone',
description: 'Faithful reproduction of the classic M16A1 rifle',
status: 'planning' as const,
totalCost: 0,
plannedDate: '2024-04-01',
components: {
total: 18,
completed: 0,
categories: {
'Upper': 0,
'Lower': 0,
'Accessory': 0
}
},
tags: ['Retro', '5.56 NATO', '20" Barrel'],
image: 'https://picsum.photos/400/250?random=6'
}
];
type MyBuildStatus = 'completed' | 'in-progress' | 'planning';
type SortField = 'name' | 'status' | 'totalCost' | 'completedDate';
type SortDirection = 'asc' | 'desc';
export default function MyBuildsPage() {
const [sortField, setSortField] = useState<SortField>('completedDate');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [selectedStatus, setSelectedStatus] = useState<MyBuildStatus | 'all'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Filter builds
const filteredMyBuilds = sampleMyBuilds.filter(build => {
if (selectedStatus !== 'all' && build.status !== selectedStatus) {
return false;
}
if (searchTerm && !build.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
!build.description.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
return true;
});
// Sort builds
const sortedMyBuilds = [...filteredMyBuilds].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'totalCost') {
aValue = a.totalCost;
bValue = b.totalCost;
} else if (sortField === 'status') {
aValue = a.status;
bValue = b.status;
} else if (sortField === 'completedDate') {
aValue = a.completedDate || a.startedDate || a.plannedDate || '';
bValue = b.completedDate || b.startedDate || b.plannedDate || '';
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const getStatusColor = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in-progress':
return 'bg-blue-100 text-blue-800';
case 'planning':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: MyBuildStatus) => {
switch (status) {
case 'completed':
return '✓';
case 'in-progress':
return '🔄';
case 'planning':
return '📋';
default:
return '❓';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getProgressPercentage = (build: typeof sampleMyBuilds[0]) => {
return Math.round((build.components.completed / build.components.total) * 100);
};
const totalMyBuilds = sampleMyBuilds.length;
const completedMyBuilds = sampleMyBuilds.filter(build => build.status === 'completed').length;
const inProgressMyBuilds = sampleMyBuilds.filter(build => build.status === 'in-progress').length;
const totalValue = sampleMyBuilds.reduce((sum, build) => sum + build.totalCost, 0);
return (
<main className="min-h-screen bg-gray-50">
{/* Page Title */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">My Builds</h1>
<p className="text-gray-600 mt-2">Track and manage your firearm builds</p>
</div>
</div>
{/* Build Summary */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{totalMyBuilds}</div>
<div className="text-sm text-gray-500">Total Builds</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{completedMyBuilds}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">${totalValue.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Value</div>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as MyBuildStatus | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="in-progress">In Progress</option>
<option value="planning">Planning</option>
</select>
</div>
{/* Sort by */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="completedDate">Date</option>
<option value="name">Name</option>
<option value="totalCost">Cost</option>
<option value="status">Status</option>
</select>
</div>
{/* Sort Direction */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Order</label>
<select
value={sortDirection}
onChange={(e) => setSortDirection(e.target.value as SortDirection)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
</div>
{/* New Build Button */}
<div className="flex items-end">
<button className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
+ New Build
</button>
</div>
</div>
</div>
</div>
{/* Builds Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{sortedMyBuilds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedMyBuilds.map((build) => (
<div key={build.id} className="bg-white rounded-lg shadow-sm border hover:shadow-md transition-shadow overflow-hidden">
{/* Build Image */}
<div className="h-48 bg-gray-200 relative">
<img
src={build.image}
alt={build.name}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400">
<div className="text-center text-gray-600">
<div className="text-4xl mb-2">🔫</div>
<div className="text-sm font-medium">{build.name}</div>
</div>
</div>
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(build.status)}`}>
{getStatusIcon(build.status)} {build.status}
</span>
</div>
</div>
{/* Build Content */}
<div className="p-6">
{/* Build Title and Date */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{build.name}</h3>
<p className="text-sm text-gray-600 mb-2">{build.description}</p>
<div className="text-xs text-gray-500">
{build.status === 'completed' && build.completedDate && `Completed ${formatDate(build.completedDate)}`}
{build.status === 'in-progress' && build.startedDate && `Started ${formatDate(build.startedDate)}`}
{build.status === 'planning' && build.plannedDate && `Planned for ${formatDate(build.plannedDate)}`}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Progress</span>
<span>{build.components.completed}/{build.components.total} components</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getProgressPercentage(build)}%` }}
></div>
</div>
</div>
{/* Component Categories */}
<div className="mb-4">
<div className="flex space-x-2">
{Object.entries(build.components.categories).map(([category, count]) => (
<span key={category} className="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-700">
{category}: {count}
</span>
))}
</div>
</div>
{/* Tags */}
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{build.tags.map((tag) => (
<span key={tag} className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
{tag}
</span>
))}
</div>
</div>
{/* Cost and Actions */}
<div className="flex justify-between items-center">
<div className="text-lg font-bold text-gray-900">
${build.totalCost.toFixed(2)}
</div>
<div className="flex space-x-2">
<button className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors">
View Details
</button>
<button className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm hover:bg-gray-200 transition-colors">
Edit
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-lg font-medium mb-2">No builds found</div>
<div className="text-sm">Try adjusting your filters or create a new build</div>
</div>
</div>
)}
</div>
</main>
);
}
+53 -635
View File
@@ -1,640 +1,58 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/20/solid';
import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip';
import BetaTester from "../components/BetaTester";
import Link from 'next/link';
import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
// Extract unique values for dropdowns
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
// Restrictions for filter dropdown
const restrictionOptions = [
'',
'NFA',
'SBR',
'SUPPRESSOR',
'STATE_RESTRICTIONS',
];
type SortField = 'name' | 'category' | 'price';
type SortDirection = 'asc' | 'desc';
// Restriction indicator component
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'bg-red-600 text-white',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'bg-orange-600 text-white',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'bg-purple-600 text-white',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'bg-blue-600 text-white',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'bg-yellow-600 text-black',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'bg-pink-600 text-white',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'bg-green-600 text-white',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
};
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
if (!config) return null;
export default function LandingPage() {
return (
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
<span>{config.label}</span>
<div className="bg-white font-sans">
{/* SVG Grid Background */}
<div className="relative isolate pt-1">
<svg
aria-hidden="true"
className="absolute inset-0 -z-10 w-full h-full stroke-gray-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
>
<defs>
<pattern
id="grid"
width={200}
height={200}
patternUnits="userSpaceOnUse"
>
<path d="M100 200V.5M.5 .5H200" fill="none" />
</pattern>
</defs>
<rect fill="url(#grid)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:flex lg:items-start lg:gap-x-10 lg:px-8 lg:py-40">
{/* Left: Headline, Subheading, Button */}
<div className="mx-auto max-w-2xl lg:mx-0 lg:flex-auto">
<h1 className="mt-10 text-pretty text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">
A better way to plan your next build
</h1>
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
</p>
<div className="mt-10 flex items-top gap-x-6">
<Link
href="/build"
className="btn btn-primary text-base font-semibold px-6"
>
Get Building
</Link>
</div>
</div>
{/* Right: Product Image */}
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center">
<img
alt="AR-15 Lower Receiver"
src="https://i.imgur.com/IK8FbaI.png"
className="max-w-md w-full h-auto object-contain rounded-xl"
/>
</div>
</div>
</div>
{/* Beta Tester CTA */}
<BetaTester />
</div>
);
};
// Tailwind UI Dropdown Component
const Dropdown = ({
label,
value,
onChange,
options,
placeholder = "Select option"
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{value || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
export default function Home() {
const searchParams = useSearchParams();
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedBrand, setSelectedBrand] = useState('All');
const [selectedVendor, setSelectedVendor] = useState('All');
const [priceRange, setPriceRange] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [selectedRestriction, setSelectedRestriction] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
// Read category from URL parameter on page load
useEffect(() => {
const categoryParam = searchParams.get('category');
if (categoryParam && categories.includes(categoryParam)) {
setSelectedCategory(categoryParam);
}
}, [searchParams]);
// Filter parts based on selected criteria
const filteredParts = mockProducts.filter(part => {
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
const matchesSearch = !searchTerm ||
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
// Restriction filter logic
let matchesRestriction = true;
if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
else if (selectedRestriction === 'SUPPRESSOR') matchesRestriction = !!part.restrictions?.suppressor;
else if (selectedRestriction === 'STATE_RESTRICTIONS') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
else matchesRestriction = false;
}
// Price range filtering
let matchesPrice = true;
if (priceRange) {
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
switch (priceRange) {
case 'under-100':
matchesPrice = lowestPrice < 100;
break;
case '100-300':
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
break;
case '300-500':
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
break;
case 'over-500':
matchesPrice = lowestPrice > 500;
break;
}
}
return matchesCategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
});
// Sort parts
const sortedParts = [...filteredParts].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'price') {
aValue = Math.min(...a.offers.map(offer => offer.price));
bValue = Math.min(...b.offers.map(offer => offer.price));
} else if (sortField === 'category') {
aValue = a.category.name.toLowerCase();
bValue = b.category.name.toLowerCase();
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return '↕️';
}
return sortDirection === 'asc' ? '↑' : '↓';
};
const clearFilters = () => {
setSelectedCategory('All');
setSelectedBrand('All');
setSelectedVendor('All');
setSearchTerm('');
setPriceRange('');
setSelectedRestriction('');
};
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
const flags: string[] = [];
if (restrictions?.nfa) flags.push('NFA');
if (restrictions?.sbr) flags.push('SBR');
if (restrictions?.suppressor) flags.push('SUPPRESSOR');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('STATE_RESTRICTIONS');
return flags;
};
return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
{/* Page Title */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
Parts Catalog
{selectedCategory !== 'All' && (
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
- {selectedCategory}
</span>
)}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
{selectedCategory !== 'All'
? `Showing ${selectedCategory} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Search Row */}
<div className="mb-4 flex justify-end">
<div className="w-1/2">
<SearchInput
label="Search"
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search parts..."
/>
</div>
</div>
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Category Dropdown */}
<Dropdown
label="Category"
value={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
{/* Brand Dropdown */}
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
{/* Vendor Dropdown */}
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
{/* Price Range */}
<div className="relative">
<Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' :
priceRange === '100-300' ? '$100 - $300' :
priceRange === '300-500' ? '$300 - $500' :
priceRange === 'over-500' ? '$500+' : priceRange}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{[
{ value: '', label: 'Select price range' },
{ value: 'under-100', label: 'Under $100' },
{ value: '100-300', label: '$100 - $300' },
{ value: '300-500', label: '$300 - $500' },
{ value: 'over-500', label: '$500+' }
].map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
{/* Clear Filters */}
<div className="flex items-end">
<button
onClick={clearFilters}
disabled={!hasActiveFilters}
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-4 w-4" />
Clear All
</button>
</div>
</div>
</div>
</div>
{/* Parts Display */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* View Toggle and Results Count */}
<div className="flex justify-between items-center mb-6">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
<div className="btn-group">
<button
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
>
Table
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
>
Cards
</button>
</div>
</div>
</div>
{/* Restriction Alert Example */}
{sortedParts.some(part => part.restrictions?.nfa) && (
<div className="mb-6">
<RestrictionAlert
type="warning"
title="NFA Items Detected"
message="Some items in your search require National Firearms Act registration. Please ensure compliance with all federal and state regulations."
icon="🔒"
/>
</div>
)}
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
onClick={() => handleSort('name')}
>
<div className="flex items-center space-x-1">
<span>Name</span>
<span className="text-sm">{getSortIcon('name')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Brand
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Description
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
onClick={() => handleSort('price')}
>
<div className="flex items-center space-x-1">
<span>Price</span>
<span className="text-sm">{getSortIcon('price')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-neutral-900 dark:text-white">
{part.name}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
{part.brand.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-neutral-900 dark:text-white">
{part.brand.name}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
{part.description}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<Link href={`/products/${part.id}`} legacyBehavior>
<a className="btn btn-primary btn-sm">
View Details
</a>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
</div>
</div>
</div>
</div>
)}
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedParts.map((part) => (
<ProductCard key={part.id} product={part} />
))}
</div>
)}
</div>
{/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
<span className="font-medium">Restrictions:</span>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
<span>National Firearms Act</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
<span>Short Barrel Rifle</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
<span>Sound Suppressor</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
<span>FFL Required</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺State</div>
<span>State Restrictions</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
<span>High Capacity</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
<span>SilencerShop Partner</span>
</div>
</div>
</div>
</main>
);
}
}
+766
View File
@@ -0,0 +1,766 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip';
import Link from 'next/link';
import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
// Extract unique values for dropdowns
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
// Restrictions for filter dropdown
const restrictionOptions = [
'All',
'NFA',
'SBR',
'Suppressor',
'State Restrictions',
];
type SortField = 'name' | 'category' | 'price';
type SortDirection = 'asc' | 'desc';
// Restriction indicator component
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'bg-red-600 text-white',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'bg-orange-600 text-white',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'bg-purple-600 text-white',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'bg-blue-600 text-white',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'bg-yellow-600 text-black',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'bg-pink-600 text-white',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'bg-green-600 text-white',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
};
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
if (!config) return null;
return (
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
);
};
// Tailwind UI Dropdown Component
const Dropdown = ({
label,
value,
onChange,
options,
placeholder = "Select option"
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-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">
<span className="block truncate text-neutral-900 dark:text-white">
{value || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-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"
>
<Listbox.Options>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
// Map product categories to checklist component categories
const getComponentCategory = (productCategory: string): string => {
const categoryMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper',
'Barrel': 'Upper',
'BCG': 'Upper',
'Bolt Carrier Group': 'Upper',
'Charging Handle': 'Upper',
'Gas Block': 'Upper',
'Gas Tube': 'Upper',
'Handguard': 'Upper',
'Muzzle Device': 'Upper',
'Suppressor': 'Upper',
// Lower components
'Lower Receiver': 'Lower',
'Trigger': 'Lower',
'Trigger Guard': 'Lower',
'Pistol Grip': 'Lower',
'Buffer Tube': 'Lower',
'Buffer': 'Lower',
'Buffer Spring': 'Lower',
'Stock': 'Lower',
// Accessories
'Magazine': 'Accessory',
'Sights': 'Accessory',
'Optic': 'Accessory',
'Scope': 'Accessory',
'Red Dot': 'Accessory',
};
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
};
// Map product categories to specific checklist component names
const getMatchingComponentName = (productCategory: string): string => {
const componentMap: Record<string, string> = {
'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() {
const searchParams = useSearchParams();
const router = useRouter();
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedBrand, setSelectedBrand] = useState('All');
const [selectedVendor, setSelectedVendor] = useState('All');
const [priceRange, setPriceRange] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [selectedRestriction, setSelectedRestriction] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
const selectedParts = useBuildStore((state) => state.selectedParts);
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
// Read category from URL parameter on page load
useEffect(() => {
const categoryParam = searchParams.get('category');
if (categoryParam && categories.includes(categoryParam)) {
setSelectedCategory(categoryParam);
}
}, [searchParams]);
// Filter parts based on selected criteria
const filteredParts = mockProducts.filter(part => {
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
const matchesSearch = !searchTerm ||
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
// Restriction filter logic
let matchesRestriction = true;
if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
else matchesRestriction = false;
}
// Price range filtering
let matchesPrice = true;
if (priceRange) {
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
switch (priceRange) {
case 'under-100':
matchesPrice = lowestPrice < 100;
break;
case '100-300':
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
break;
case '300-500':
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
break;
case 'over-500':
matchesPrice = lowestPrice > 500;
break;
}
}
return matchesCategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
});
// Sort parts
const sortedParts = [...filteredParts].sort((a, b) => {
let aValue: any, bValue: any;
if (sortField === 'price') {
aValue = Math.min(...a.offers.map(offer => offer.price));
bValue = Math.min(...b.offers.map(offer => offer.price));
} else if (sortField === 'category') {
aValue = a.category.name.toLowerCase();
bValue = b.category.name.toLowerCase();
} else {
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return '↕️';
}
return sortDirection === 'asc' ? '↑' : '↓';
};
const clearFilters = () => {
setSelectedCategory('All');
setSelectedBrand('All');
setSelectedVendor('All');
setSearchTerm('');
setPriceRange('');
setSelectedRestriction('');
};
const hasActiveFilters = selectedCategory !== 'All' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
const flags: string[] = [];
if (restrictions?.nfa) flags.push('NFA');
if (restrictions?.sbr) flags.push('SBR');
if (restrictions?.suppressor) flags.push('Suppressor');
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
return flags;
};
const handleAdd = (part: Product) => {
setAddedPartIds((prev) => [...prev, part.id]);
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
};
return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
{/* Page Title */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
Parts Catalog
{selectedCategory !== 'All' && (
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
- {selectedCategory}
</span>
)}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
{selectedCategory !== 'All'
? `Showing ${selectedCategory} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Search Row */}
<div className="mb-3 flex justify-end">
<div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
{isSearchExpanded ? (
<div className="flex items-center gap-2 w-full justify-end">
<div className="flex-1 max-w-md">
<SearchInput
label=""
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search parts..."
/>
</div>
<button
onClick={() => {
setIsSearchExpanded(false);
setSearchTerm('');
}}
className="p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors flex-shrink-0"
aria-label="Close search"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
) : (
<button
onClick={() => setIsSearchExpanded(true)}
className="p-2 text-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"
aria-label="Open search"
>
<MagnifyingGlassIcon className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Filters Row */}
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
{/* Category Dropdown */}
<div className="col-span-1">
<Dropdown
label="Category"
value={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
</div>
{/* Brand Dropdown */}
<div className="col-span-1">
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
</div>
{/* Vendor Dropdown */}
<div className="col-span-1">
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
</div>
{/* Price Range */}
<div className="col-span-1">
<Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-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">
<span className="block truncate text-neutral-900 dark:text-white">
{priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' :
priceRange === '100-300' ? '$100 - $300' :
priceRange === '300-500' ? '$300 - $500' :
priceRange === 'over-500' ? '$500+' : priceRange}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-neutral-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-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"
>
<Listbox.Options>
{[
{ value: '', label: 'Select price range' },
{ value: 'under-100', label: 'Under $100' },
{ value: '100-300', label: '$100 - $300' },
{ value: '300-500', label: '$300 - $500' },
{ value: 'over-500', label: '$500+' }
].map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Restriction Filter */}
<div className="col-span-1">
<Dropdown
label="Restriction"
value={selectedRestriction}
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
</div>
{/* Clear Filters */}
<div className="col-span-1 flex items-end">
<button
onClick={clearFilters}
disabled={!hasActiveFilters}
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-3.5 w-3.5" />
Clear All
</button>
</div>
</div>
</div>
</div>
{/* Parts Display */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* View Toggle and Results Count */}
<div className="flex justify-between items-center mb-6">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
<div className="btn-group">
<button
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
aria-label="Card view"
>
<Squares2X2Icon className="h-5 w-5" />
</button>
</div>
</div>
</div>
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
onClick={() => handleSort('price')}
>
<div className="flex items-center space-x-1">
<span>Price</span>
<span className="text-sm">{getSortIcon('price')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]">
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700">
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
</div>
<div>
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
{part.name}
</Link>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{(() => {
// Find if this part is already selected for any component
const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
// Find the appropriate component based on category match
const matchingComponentName = getMatchingComponentName(part.category.name);
const matchingComponent = (buildGroups as {components: any[]}[]).flatMap((group) => group.components).find((component: any) =>
component.name === matchingComponentName && !selectedParts[component.id]
);
if (selectedComponentId) {
return (
<button
className="btn btn-outline btn-sm"
onClick={() => removePartForComponent(selectedComponentId)}
>
Remove
</button>
);
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
return (
<button
className="btn btn-neutral btn-sm flex items-center gap-1"
onClick={() => {
selectPartForComponent(matchingComponent.id, {
id: part.id,
name: part.name,
image_url: part.image_url,
brand: part.brand,
category: part.category,
offers: part.offers,
});
router.push('/build');
}}
>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</button>
);
} else {
return (
<span className="text-xs text-gray-400">Part Selected</span>
);
}
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
</div>
</div>
</div>
</div>
)}
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedParts.map((part) => (
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
))}
</div>
)}
</div>
{/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
<span className="font-medium">Restrictions:</span>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
<span>National Firearms Act</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
<span>Short Barrel Rifle</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
<span>Sound Suppressor</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
<span>FFL Required</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺State</div>
<span>State Restrictions</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
<span>High Capacity</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
<span>SilencerShop Partner</span>
</div>
</div>
</div>
</main>
);
}
+48 -8
View File
@@ -6,6 +6,7 @@ import { mockProducts } from '@/mock/product';
import RestrictionAlert from '@/components/RestrictionAlert';
import { StarIcon } from '@heroicons/react/20/solid';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
export default function ProductDetailsPage() {
const params = useParams();
@@ -14,6 +15,8 @@ export default function ProductDetailsPage() {
const product = mockProducts.find(p => p.id === productId);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [selectedOffer, setSelectedOffer] = useState(0);
const [addSuccess, setAddSuccess] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
if (!product) {
return (
@@ -35,14 +38,51 @@ export default function ProductDetailsPage() {
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
: 0;
const handleAddToBuild = () => {
// Map category to component ID
const categoryToComponentMap: Record<string, string> = {
'Barrel': 'barrel',
'Upper Receiver': 'upper',
'Suppressor': 'suppressor',
'BCG': 'bcg',
'Charging Handle': 'charging-handle',
'Handguard': 'handguard',
'Gas Block': 'gas-block',
'Gas Tube': 'gas-tube',
'Muzzle Device': 'muzzle-device',
'Lower Receiver': 'lower',
'Trigger': 'trigger',
'Pistol Grip': 'pistol-grip',
'Buffer Tube': 'buffer-tube',
'Buffer': 'buffer',
'Buffer Spring': 'buffer-spring',
'Stock': 'stock',
'Magazine': 'magazine',
'Sights': 'sights'
};
const componentId = categoryToComponentMap[product.category.name] || product.category.id;
selectPartForComponent(componentId, {
id: product.id,
name: product.name,
image_url: product.image_url,
brand: product.brand,
category: product.category,
offers: product.offers
});
setAddSuccess(true);
setTimeout(() => setAddSuccess(false), 1500);
};
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<div className="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href={`/products?category=${product.category.id}`}>{product.category.name}</a></li>
<li><a href="/parts">Parts</a></li>
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
<li>{product.name}</li>
</ul>
</div>
@@ -112,11 +152,8 @@ export default function ProductDetailsPage() {
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{product.brand.name}
</div>
<div className="flex items-center gap-2">
<span className="text-2xl">{product.category.icon}</span>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{product.category.name}
</span>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{product.category.name}
</div>
</div>
</div>
@@ -172,13 +209,16 @@ export default function ProductDetailsPage() {
{/* Add to Build Button */}
<div className="flex gap-4">
<button className="btn btn-primary flex-1">
<button className="btn btn-primary flex-1" onClick={handleAddToBuild}>
Add to Current Build
</button>
<button className="btn btn-outline">
Save for Later
</button>
</div>
{addSuccess && (
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
)}
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useAuthStore } from '@/store/useAuthStore';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const { setSession, setLoading } = useAuthStore();
useEffect(() => {
setSession(session);
setLoading(status === 'loading');
}, [session, status, setSession, setLoading]);
return <>{children}</>;
}
+40
View File
@@ -0,0 +1,40 @@
export default function BetaTester() {
return (
<div className="bg-gray-900 py-16 sm:py-24 lg:py-32">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 lg:grid-cols-12 lg:gap-8 lg:px-8">
<h2 className="max-w-xl text-balance text-3xl font-semibold tracking-tight text-white sm:text-4xl lg:col-span-7">
Interested in being a beta tester? Join the beta tester list.
</h2>
<form className="w-full max-w-md lg:col-span-5 lg:pt-2">
<div className="flex gap-x-4">
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
required
placeholder="Enter your email"
autoComplete="email"
className="min-w-0 flex-auto rounded-md bg-white/5 px-3.5 py-2 text-base text-white outline outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
/>
<button
type="submit"
className="flex-none rounded-md bg-primary px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Join The List
</button>
</div>
<p className="mt-4 text-sm/6 text-gray-300">
We care about your data. Read our{' '}
<a href="#" className="font-semibold text-white">
privacy&nbsp;policy
</a>
.
</p>
</form>
</div>
</div>
)
}
+139 -55
View File
@@ -3,42 +3,153 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import ThemeSwitcher from './ThemeSwitcher';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useState, ReactNode } from 'react';
interface MenuItem {
label: string;
href?: string;
active?: boolean;
onClick?: () => void;
custom?: ReactNode;
}
export default function Navbar() {
const pathname = usePathname();
const { data: session, status } = useSession();
const loading = status === 'loading';
const [menuOpen, setMenuOpen] = useState(false);
const navItems = [
{ href: '/', label: 'Parts Catalog' },
{ href: '/build', label: 'Build Checklist' },
{ href: '/builds', label: 'My Builds' },
{ href: '/parts', label: 'Parts Catalog' },
{ href: '/build', label: 'Plan a Build' },
{ href: '/my-builds', label: 'My Builds' },
];
return (
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-primary-600 dark:bg-primary-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">🔫</span>
</div>
<span className="text-xl font-bold text-neutral-900 dark:text-white">
Pew Builder
</span>
</Link>
</div>
// Dropdown menu items
const rawMenuItems = [
session?.user
? {
label: 'Profile',
href: '/account/profile',
active: pathname === '/account/profile',
onClick: () => setMenuOpen(false),
}
: undefined,
session?.user
? {
label: 'Sign Out',
onClick: () => {
setMenuOpen(false);
signOut({ callbackUrl: '/' });
},
}
: {
label: 'Sign In',
onClick: () => {
setMenuOpen(false);
signIn();
},
},
{
label: 'Theme',
custom: <ThemeSwitcher />,
},
];
function isMenuItem(item: unknown): item is MenuItem {
return typeof item === 'object' && item !== null && 'label' in item;
}
const menuItems = rawMenuItems.filter(isMenuItem);
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-8">
return (
<>
{/* Top Bar */}
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
<div className="relative">
{loading ? null : session?.user ? (
<>
<button
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold"
aria-haspopup="true"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
My Profile
{session.user.name && (
<span className="hidden sm:inline text-white font-medium text-sm">({session.user.name})</span>
)}
</button>
{menuOpen && (
<div
className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-md shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none divide-y divide-neutral-100 dark:divide-neutral-700 animate-fade-in"
tabIndex={-1}
onBlur={() => setMenuOpen(false)}
>
<div className="py-1">
{session?.user && (
<div className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400 border-b border-neutral-100 dark:border-neutral-700">
{session.user.email}
</div>
)}
{menuItems.map((item, idx) =>
item.custom ? (
<div key={idx} className="px-4 py-2 flex items-center justify-between">
<span className="text-sm text-neutral-700 dark:text-neutral-200">Theme</span>
{item.custom}
</div>
) : item.href ? (
<Link
key={idx}
href={item.href}
className={`block px-4 py-2 text-sm rounded transition-colors ${
item.active
? 'bg-primary/10 text-primary font-semibold'
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
onClick={item.onClick}
>
{item.label}
</Link>
) : (
<button
key={idx}
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded transition-colors"
onClick={item.onClick}
>
{item.label}
</button>
)
)}
</div>
</div>
)}
</>
) : (
<button
className="btn btn-sm btn-primary font-semibold px-4 py-1"
onClick={() => signIn()}
>
Sign In
</button>
)}
</div>
</div>
{/* Subnav */}
<nav className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center h-14">
{/* Left: Nav Links */}
<div className="flex items-center space-x-6">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
pathname === item.href
? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300'
: 'text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700'
? 'text-primary font-semibold underline underline-offset-4'
: 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
}`}
>
{item.label}
@@ -46,41 +157,14 @@ export default function Navbar() {
))}
</div>
{/* Theme Switcher */}
{/* Right: Search */}
<div className="flex items-center space-x-4">
<ThemeSwitcher />
{/* Mobile menu button */}
<button className="md:hidden p-2 rounded-md text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-700">
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.label}
</Link>
);
})}
</div>
</div>
</nav>
</nav>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { usePathname } from 'next/navigation';
import Navbar from './Navbar';
export default function NavigationWrapper() {
const pathname = usePathname();
const isAccountPage = pathname?.startsWith('/account');
if (isAccountPage) {
return null;
}
return <Navbar />;
}
+20 -2
View File
@@ -6,6 +6,8 @@ import { Product } from '@/mock/product';
interface ProductCardProps {
product: Product;
onAdd?: () => void;
added?: boolean;
}
function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
@@ -17,7 +19,7 @@ function getRestrictionFlags(restrictions?: Product['restrictions']): string[] {
return flags;
}
export default function ProductCard({ product }: ProductCardProps) {
export default function ProductCard({ product, onAdd, added }: ProductCardProps) {
const [imageError, setImageError] = useState(false);
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
@@ -86,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
<figure className="relative">
<img
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
src={imageError ? '/window.svg' : product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
onError={() => setImageError(true)}
@@ -116,6 +118,22 @@ export default function ProductCard({ product }: ProductCardProps) {
View Details
</a>
</Link>
{onAdd && (
<button
className="btn btn-neutral btn-sm ml-2 flex items-center gap-1"
onClick={onAdd}
disabled={added}
>
{added ? (
'Added!'
) : (
<>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</>
)}
</button>
)}
</div>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { AuthProvider } from './AuthProvider';
import { ThemeProvider } from './ThemeProvider';
import NavigationWrapper from './NavigationWrapper';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<AuthProvider>
<ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<NavigationWrapper />
{children}
</div>
</ThemeProvider>
</AuthProvider>
</SessionProvider>
);
}
+533 -768
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { Session } from 'next-auth';
interface AuthStore {
session: Session | null;
isLoading: boolean;
setSession: (session: Session | null) => void;
setLoading: (isLoading: boolean) => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
session: null,
isLoading: true,
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
}));
+53
View File
@@ -0,0 +1,53 @@
import { create, StateCreator } from 'zustand';
import { persist } from 'zustand/middleware';
export interface BuildPart {
id: string;
name: string;
image_url: string;
brand: {
id: string;
name: string;
logo?: string;
};
category: {
id: string;
name: string;
};
offers: Array<{
price: number;
url: string;
vendor: {
name: string;
logo?: string;
};
inStock?: boolean;
shipping?: string;
}>;
}
export interface BuildState {
selectedParts: Record<string, BuildPart | null>; // key: checklist component id
selectPartForComponent: (componentId: string, part: BuildPart) => void;
removePartForComponent: (componentId: string) => void;
clearBuild: () => void;
}
const buildStoreCreator: StateCreator<BuildState> = (set) => ({
selectedParts: {},
selectPartForComponent: (componentId, part) => set((state) => ({
selectedParts: { ...state.selectedParts, [componentId]: part },
})),
removePartForComponent: (componentId) => set((state) => {
const updated = { ...state.selectedParts };
delete updated[componentId];
return { selectedParts: updated };
}),
clearBuild: () => set({ selectedParts: {} }),
});
export const useBuildStore = create<BuildState>()(
persist(buildStoreCreator, {
name: 'current-build-storage',
})
);
+18 -52
View File
@@ -10,20 +10,6 @@ module.exports = {
theme: {
extend: {
colors: {
// Primary brand colors
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
// Secondary accent colors
accent: {
50: '#fef2f2',
@@ -105,50 +91,30 @@ module.exports = {
daisyui: {
themes: [
{
light: {
"primary": "#0ea5e9",
"primary-content": "#ffffff",
"secondary": "#ef4444",
"secondary-content": "#ffffff",
"accent": "#f59e0b",
"accent-content": "#ffffff",
"neutral": "#737373",
"neutral-content": "#ffffff",
"base-100": "#ffffff",
"base-200": "#f5f5f5",
"base-300": "#e5e5e5",
"base-content": "#171717",
"info": "#0ea5e9",
"success": "#22c55e",
"warning": "#f59e0b",
"error": "#ef4444",
},
dark: {
"primary": "#38bdf8",
"primary-content": "#ffffff",
"secondary": "#f87171",
"secondary-content": "#ffffff",
"accent": "#fbbf24",
"accent-content": "#ffffff",
"neutral": "#a3a3a3",
"neutral-content": "#ffffff",
"base-100": "#171717",
"base-200": "#262626",
"base-300": "#404040",
"base-content": "#ffffff",
"info": "#38bdf8",
"success": "#4ade80",
"warning": "#fbbf24",
"error": "#f87171",
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,
prefix: "",
logs: true,
themeRoot: ":root",
logs: false,
rtl: false,
prefix: '',
// 'pew' is the default theme
},
};