From ccc6e41724c49e41b574ea6d44554066c465c1dd Mon Sep 17 00:00:00 2001 From: Sean S Date: Sun, 29 Jun 2025 15:58:03 -0400 Subject: [PATCH 1/2] add state management and other stuff --- next.config.ts | 9 +- package-lock.json | 36 +- package.json | 3 +- src/app/build/page.tsx | 408 +++++++--- src/app/page.tsx | 4 +- src/app/parts/page.tsx | 303 ++++++-- src/app/products/[id]/page.tsx | 56 +- src/components/Navbar.tsx | 2 +- src/components/ProductCard.tsx | 15 +- src/mock/product.ts | 1301 +++++++++++++------------------- src/store/useBuildStore.ts | 53 ++ 11 files changed, 1221 insertions(+), 969 deletions(-) create mode 100644 src/store/useBuildStore.ts diff --git a/next.config.ts b/next.config.ts index 0904350..862135b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,14 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'placehold.co', - port: '', - pathname: '/**', - }, - ], + remotePatterns: [], }, }; diff --git a/package-lock.json b/package-lock.json index 3c23630..d5d9c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "daisyui": "^4.7.3", "next": "15.3.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zustand": "^5.0.6" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1258,7 +1259,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" @@ -2545,7 +2546,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": { @@ -6826,6 +6827,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 + } + } } } } diff --git a/package.json b/package.json index 8e01a43..027521e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "daisyui": "^4.7.3", "next": "15.3.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zustand": "^5.0.6" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/build/page.tsx b/src/app/build/page.tsx index 9ee7ee4..a3aca2f 100644 --- a/src/app/build/page.tsx +++ b/src/app/build/page.tsx @@ -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 = { + '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 = { + // 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('name'); const [sortDirection, setSortDirection] = useState('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 (
{/* Page Title */}
-

AR-15 Build Checklist

-

Track your build progress and find required components

+

Plan Your Build

{/* Build Summary */}
-
+
{allComponents.length}
Total Components
@@ -320,26 +402,138 @@ export default function BuildPage() {
{allComponents.length - completedCount}
Remaining
-
-
${totalEstimatedCost}
-
Estimated Cost
+
+
+
${actualTotalCost.toFixed(2)}
+
Total Cost
+
+
+ {/* Clear Build Modal */} + setShowClearModal(false)} className="fixed z-50 inset-0 overflow-y-auto"> +
+ +
+ + {/* Restriction Alerts */} + {restrictedParts.length > 0 && ( +
+
+
+
+
+ + {restrictedParts.length} restriction{restrictedParts.length > 1 ? 's' : ''} detected + +
+ +
+ + {showRestrictionAlerts && ( +
+ {hasNFAItems && ( +
+
+ πŸ”’ +
+
NFA Items in Your Build
+
+ Your build contains items that require National Firearms Act registration. +
+
+
+
+ )} + {hasSuppressors && ( +
+
+ πŸ”‡ +
+
Suppressor in Your Build
+
+ Sound suppressor requires NFA registration. Processing times: 6-12 months. +
+
+
+
+ )} + {hasStateRestrictions && ( +
+
+ πŸ—ΊοΈ +
+
State Restrictions Apply
+
+ Some items may be restricted in certain states. Verify local laws. +
+
+
+
+ )} +
+ )} +
+
+ )} + {/* Search and Filters */}
-
+
{/* Filters Row */} -
+
{/* Category Dropdown */} -
+
+ handleSort(e.target.value as SortField)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900" + className="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-gray-900 text-sm" > @@ -376,8 +570,8 @@ export default function BuildPage() {
{/* Clear Filters */} -
-
@@ -387,10 +581,10 @@ export default function BuildPage() { {/* Build Components Table */}
-
-
+
+
- + - @@ -429,7 +620,7 @@ export default function BuildPage() { Notes @@ -446,78 +637,103 @@ export default function BuildPage() { return ( {/* Group Header */} - - + - {/* Group Components */} - {groupComponents.map((component) => ( - - - - - - - - - - ))} + {groupComponents.map((component) => { + const selected = selectedParts[component.id]; + return ( + + + + + + + + + ); + })} ); }) @@ -547,7 +763,7 @@ export default function BuildPage() { )}
- Total Value: ${sortedComponents.reduce((sum, component) => sum + component.estimatedPrice, 0).toFixed(2)} + Total Value: ${actualTotalCost.toFixed(2)}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ada719..088b87f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -34,7 +34,7 @@ export default function LandingPage() {

Get Building @@ -46,7 +46,7 @@ export default function LandingPage() { AR-15 Lower Receiver
diff --git a/src/app/parts/page.tsx b/src/app/parts/page.tsx index 8246ec1..22530a5 100644 --- a/src/app/parts/page.tsx +++ b/src/app/parts/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useSearchParams, useRouter } from 'next/navigation'; import { Listbox, Transition } from '@headlessui/react'; -import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon } from '@heroicons/react/20/solid'; +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'; @@ -12,6 +12,8 @@ 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)))]; @@ -20,11 +22,11 @@ const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part. // Restrictions for filter dropdown const restrictionOptions = [ - '', + 'All', 'NFA', 'SBR', - 'SUPPRESSOR', - 'STATE_RESTRICTIONS', + 'Suppressor', + 'State Restrictions', ]; type SortField = 'name' | 'category' | 'price'; @@ -112,13 +114,13 @@ const Dropdown = ({ {label} - + {value || placeholder} @@ -128,7 +130,7 @@ const Dropdown = ({ 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" + 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" > {options.map((option, optionIdx) => ( @@ -148,7 +150,7 @@ const Dropdown = ({ {selected ? ( - ) : null} @@ -163,8 +165,78 @@ const Dropdown = ({ ); }; +// Map product categories to checklist component categories +const getComponentCategory = (productCategory: string): string => { + const categoryMap: Record = { + // 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 = { + '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'); @@ -174,6 +246,11 @@ export default function Home() { const [sortField, setSortField] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); + const [addedPartIds, setAddedPartIds] = useState([]); + 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(() => { @@ -198,8 +275,8 @@ export default function Home() { 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 if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor; + else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0); else matchesRestriction = false; } @@ -280,11 +357,16 @@ export default function Home() { 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'); + 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 (
{/* Page Title */} @@ -309,56 +391,86 @@ export default function Home() { {/* Search and Filters */}
-
+
{/* Search Row */} -
-
- +
+
+ {isSearchExpanded ? ( +
+
+ +
+ +
+ ) : ( + + )}
{/* Filters Row */} -
+
{/* Category Dropdown */} - +
+ +
{/* Brand Dropdown */} - +
+ +
{/* Vendor Dropdown */} - +
+ +
{/* Price Range */} -
+
Price Range - + {priceRange === '' ? 'Select price range' : priceRange === 'under-100' ? 'Under $100' : @@ -368,7 +480,7 @@ export default function Home() { @@ -378,7 +490,7 @@ export default function Home() { 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" + 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" > {[ @@ -404,7 +516,7 @@ export default function Home() { {selected ? ( - ) : null} @@ -418,26 +530,28 @@ export default function Home() {
{/* Restriction Filter */} - +
+ +
{/* Clear Filters */} -
+
@@ -480,24 +594,12 @@ export default function Home() {
- {/* Restriction Alert Example */} - {sortedParts.some(part => part.restrictions?.nfa) && ( -
- -
- )} - {/* Table View */} {viewMode === 'table' && (
-
+
Status @@ -413,15 +607,12 @@ export default function BuildPage() { {getSortIcon('category')} - Description - handleSort('estimatedPrice')} >
- Est. Price + Price {getSortIcon('estimatedPrice')}
- Actions + Selected Product
+
-
-
- - {group.name === 'Upper Parts' ? 'πŸ”«' : - group.name === 'Lower Parts' ? 'πŸ”§' : 'πŸ“¦'} - -
-
-
-

{group.name}

-

{group.description}

+
+

{group.name}

-
+
{groupComponents.length} components
- - {component.status} - - -
- {component.name} -
-
- {component.required ? 'Required' : 'Optional'} -
-
- - {component.category} - - -
- {component.description} -
-
-
- ${component.estimatedPrice} -
-
-
- {component.notes} -
-
-
- - Find Parts - -
-
+ {selected ? ( + + Selected + + ) : ( + + {component.status} + + )} + + {selected ? ( +
+
+ + {selected.name} + +
+
+ {selected.brand.name} · {component.required ? 'Required' : 'Optional'} +
+
+ ) : ( +
+
+ {component.name} +
+
+ {component.required ? 'Required' : 'Optional'} +
+
+ )} +
+ + {getProductCategoryForComponent(component.name)} + + + {selected ? ( +
+ ${Math.min(...selected.offers?.map(offer => offer.price) || [0]).toFixed(2)} +
+ ) : ( +
+ β€” +
+ )} +
+
+ {component.notes} +
+
+ {selected ? ( + + ) : ( + + Find Parts + + )} +
- + ))} @@ -577,7 +720,7 @@ export default function Home() { {viewMode === 'cards' && (
{sortedParts.map((part) => ( - + handleAdd(part)} added={addedPartIds.includes(part.id)} /> ))}
)} diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx index 86aae04..039ef1d 100644 --- a/src/app/products/[id]/page.tsx +++ b/src/app/products/[id]/page.tsx @@ -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 = { + '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 (
{/* Breadcrumb */} @@ -112,11 +152,8 @@ export default function ProductDetailsPage() {
{product.brand.name}
-
- {product.category.icon} - - {product.category.name} - +
+ {product.category.name}
@@ -172,13 +209,16 @@ export default function ProductDetailsPage() { {/* Add to Build Button */}
-
+ {addSuccess && ( +
Added to build!
+ )} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8bc8901..2608dec 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -18,7 +18,7 @@ export default function Navbar() { <> {/* Top Bar */}
- Pew Builder + Pew Builder
diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index 1b380a8..85f7284 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -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) {
{product.name} setImageError(true)} @@ -116,6 +118,15 @@ export default function ProductCard({ product }: ProductCardProps) { View Details + {onAdd && ( + + )}
diff --git a/src/mock/product.ts b/src/mock/product.ts index 3d0305b..fac6dfd 100644 --- a/src/mock/product.ts +++ b/src/mock/product.ts @@ -13,7 +13,6 @@ export interface Product { category: { id: string; name: string; - icon?: string; }; specifications?: { weight?: string; @@ -21,6 +20,7 @@ export interface Product { material?: string; finish?: string; caliber?: string; + capacity?: string; compatibility?: string[]; }; restrictions?: { @@ -56,21 +56,20 @@ export const mockProducts: Product[] = [ name: 'Faxon 16" Gunner Barrel - 5.56 NATO', description: 'Lightweight, high-performance AR-15 barrel.', longDescription: 'The Faxon 16" Gunner Profile barrel offers the perfect balance of weight and performance. The Gunner profile reduces weight while maintaining accuracy and heat dissipation. Features a 1:8 twist rate for optimal bullet stabilization with a wide range of ammunition.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Barrel', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Barrel+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Barrel+Side', - 'https://placehold.co/600x400/1f2937/ffffff?text=Barrel+Chamber' + '/window.svg', + '/window.svg', + '/window.svg' ], brand: { id: 'b1', name: 'Faxon Firearms', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=Faxon' + logo: '/window.svg' }, category: { id: 'c1', - name: 'Barrel', - icon: 'πŸ”«' + name: 'Barrel' }, specifications: { weight: '1.5 lbs', @@ -92,7 +91,7 @@ export const mockProducts: Product[] = [ url: 'https://primaryarms.com/faxon-16-gunner-barrel', vendor: { name: 'Primary Arms', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=PA' + logo: '/window.svg' }, inStock: true, shipping: 'Free shipping on orders over $150' @@ -102,7 +101,7 @@ export const mockProducts: Product[] = [ url: 'https://brownells.com/faxon-16-gunner-barrel', vendor: { name: 'Brownells', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=Brownells' + logo: '/window.svg' }, inStock: true, shipping: '$9.99 flat rate' @@ -132,21 +131,20 @@ export const mockProducts: Product[] = [ name: 'BCM M4 Upper Receiver', description: 'Forged upper with M4 feed ramps.', longDescription: 'The BCM M4 Upper Receiver is forged from 7075-T6 aluminum and features M4 feed ramps for reliable feeding. Includes forward assist and dust cover. Mil-spec design ensures compatibility with standard AR-15 parts.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Upper', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Upper+Left', - 'https://placehold.co/600x400/1f2937/ffffff?text=Upper+Right', - 'https://placehold.co/600x400/1f2937/ffffff?text=Upper+Inside' + '/window.svg', + '/window.svg', + '/window.svg' ], brand: { id: 'b2', name: 'BCM', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=BCM' + logo: '/window.svg' }, category: { id: 'c2', - name: 'Upper Receiver', - icon: 'πŸ”§' + name: 'Upper Receiver' }, specifications: { weight: '0.8 lbs', @@ -166,7 +164,7 @@ export const mockProducts: Product[] = [ url: 'https://rainierarms.com/bcm-m4-upper', vendor: { name: 'Rainier Arms', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=Rainier' + logo: '/window.svg' }, inStock: true, shipping: 'Free shipping' @@ -189,280 +187,207 @@ export const mockProducts: Product[] = [ name: 'SilencerCo Omega 300 Suppressor', description: 'Multi-caliber rifle suppressor with excellent sound reduction.', longDescription: 'The SilencerCo Omega 300 is a versatile, lightweight suppressor designed for multiple calibers including .308, .300 Win Mag, and .223. Features a titanium tube with stainless steel baffles for durability and performance.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Suppressor', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Suppressor+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Suppressor+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b3', name: 'SilencerCo', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=SilencerCo' + logo: '/window.svg' }, category: { id: 'c3', - name: 'Suppressor', - icon: 'πŸ”‡' + name: 'Suppressor' }, specifications: { - weight: '14.4 oz', + weight: '14 oz', length: '7.2 inches', material: 'Titanium / Stainless Steel', finish: 'Cerakote', - caliber: '.308, .300 Win Mag, .223, 6.5 Creedmoor' + caliber: '.308, .300 Win Mag, .223', + compatibility: ['AR-15', 'AR-10', 'Bolt Actions'] }, restrictions: { nfa: true, sbr: false, suppressor: true, - stateRestrictions: ['CA', 'NY', 'IL', 'HI'] + stateRestrictions: ['CA', 'NY', 'IL'] }, offers: [ { price: 899.99, url: 'https://silencershop.com/omega-300', vendor: { - name: 'Silencer Shop', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=SS' + name: 'SilencerShop', + logo: '/window.svg' }, inStock: true, - shipping: 'Free shipping + tax stamp included' + shipping: 'Free shipping' } ], reviews: [ { id: 'r4', rating: 5, - comment: 'Amazing sound reduction and build quality. Worth the wait for the tax stamp!', - user: 'SuppressedLife', - date: '2024-01-05' + comment: 'Excellent sound reduction and build quality. Worth the wait for the stamp.', + user: 'SuppressorGuy', + date: '2024-01-18' } ], - relatedProducts: ['7', '8'], - compatibility: ['Barrel', 'Muzzle Device', 'Mounting System'] + relatedProducts: ['1', '2'], + compatibility: ['Muzzle Device', 'Barrel'] }, { id: '4', - name: 'Geissele SSA-E Trigger', - description: 'Two-stage trigger with 3.5lb total pull weight and enhanced reliability.', - longDescription: 'The Geissele SSA-E trigger is designed for precision and reliability, offering a crisp break and short reset. Ideal for both competition and duty use.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Trigger', + name: 'Toolcraft BCG - Nitride', + description: 'Mil-spec bolt carrier group with nitride finish.', + longDescription: 'The Toolcraft BCG features a properly staked gas key, shot peened bolt, and nitride finish for durability. Built to mil-spec standards for reliable performance in any AR-15.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Trigger+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Trigger+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b4', - name: 'Geissele', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=Geissele' + name: 'Toolcraft', + logo: '/window.svg' }, category: { id: 'c4', - name: 'Trigger', - icon: 'πŸ”«' + name: 'BCG' }, specifications: { - weight: '2.6 oz', - material: 'Steel', - finish: 'Nickel Boron', - compatibility: ['AR-15', 'M4'] + weight: '11.6 oz', + material: '8620 Steel', + finish: 'Nitride', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, - restrictions: {}, offers: [ { - price: 249.99, - url: 'https://geissele.com/ssa-e', + price: 89.99, + url: 'https://primaryarms.com/toolcraft-bcg', vendor: { - name: 'Geissele', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=Geissele' + name: 'Primary Arms', + logo: '/window.svg' }, inStock: true, - shipping: 'Free shipping' + shipping: 'Free shipping on orders over $150' } ], reviews: [ { id: 'r5', rating: 5, - comment: 'Best trigger I have ever used!', - user: 'TriggerFan', - date: '2024-02-01' + comment: 'Great BCG for the price. Properly staked and finished.', + user: 'AR_Enthusiast', + date: '2024-01-12' } ], - relatedProducts: ['1', '2'], - compatibility: ['Lower Receiver'] + relatedProducts: ['2', '5'], + compatibility: ['Upper Receiver', 'Charging Handle'] }, { id: '5', - name: 'Magpul PMAG 30-Round Magazine', - description: '30-round polymer magazine with anti-tilt follower and dust cover.', - longDescription: 'The Magpul PMAG is the industry standard for AR-15 magazines, offering reliability and durability in all conditions.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Magazine', + name: 'Radian Raptor Charging Handle', + description: 'Ambidextrous charging handle with enhanced ergonomics.', + longDescription: 'The Radian Raptor features oversized latches for easy manipulation and a unique design that reduces gas blowback. Made from 7075-T6 aluminum with a hardcoat anodized finish.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Magazine+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Magazine+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b5', - name: 'Magpul', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=Magpul' + name: 'Radian Weapons', + logo: '/window.svg' }, category: { id: 'c5', - name: 'Magazine', - icon: 'πŸ₯' + name: 'Charging Handle' }, specifications: { - weight: '4.5 oz', - material: 'Polymer', - compatibility: ['AR-15'] + weight: '1.2 oz', + material: '7075-T6 Aluminum', + finish: 'Hardcoat Anodized', + compatibility: ['AR-15', 'M4', 'M16'] }, restrictions: { - stateRestrictions: ['CA', 'NY', 'NJ', 'MA'] + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, offers: [ { - price: 14.99, - url: 'https://magpul.com/pmag', + price: 79.99, + url: 'https://primaryarms.com/radian-raptor', vendor: { - name: 'Magpul', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=Magpul' + name: 'Primary Arms', + logo: '/window.svg' }, inStock: true, - shipping: 'Flat rate $5' + shipping: 'Free shipping on orders over $150' } ], reviews: [ { id: 'r6', rating: 5, - comment: 'Runs flawlessly in my AR.', - user: 'MagFan', - date: '2024-02-10' + comment: 'Best charging handle I\'ve used. Smooth operation and great ergonomics.', + user: 'TacticalUser', + date: '2024-01-14' } ], - relatedProducts: ['6'], - compatibility: ['Lower Receiver'] + relatedProducts: ['2', '4'], + compatibility: ['Upper Receiver', 'BCG'] }, { id: '6', - name: 'BCM Gunfighter Stock', - description: 'Lightweight, durable stock with QD sling mount.', - longDescription: 'The BCM Gunfighter Stock is designed for comfort and durability, featuring a snag-free design and multiple sling attachment points.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Stock', + name: 'BCM MCMR Handguard - 13"', + description: 'Free-float M-LOK handguard with excellent heat dissipation.', + longDescription: 'The BCM MCMR features a unique design that provides excellent heat dissipation while maintaining a slim profile. Includes M-LOK slots for accessory mounting and a proprietary barrel nut.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Stock+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Stock+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b2', name: 'BCM', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=BCM' + logo: '/window.svg' }, category: { id: 'c6', - name: 'Stock', - icon: 'πŸͺ‘' + name: 'Handguard' }, specifications: { - weight: '7.5 oz', - material: 'Polymer', - compatibility: ['AR-15', 'M4'] + weight: '7.2 oz', + length: '13 inches', + material: '6061-T6 Aluminum', + finish: 'Type III Hardcoat Anodized', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, - restrictions: {}, offers: [ { - price: 59.99, - url: 'https://bravocompanyusa.com/gunfighter-stock', + price: 199.99, + url: 'https://bravocompanyusa.com/mcmr-handguard', vendor: { name: 'BCM', - logo: 'https://placehold.co/80x40/1f2937/ffffff?text=BCM' - }, - inStock: true, - shipping: 'Free shipping on orders over $100' - } - ], - reviews: [], - relatedProducts: ['5'], - compatibility: ['Buffer Tube'] - }, - { - id: '7', - name: 'Aero Precision Lower Receiver', - description: 'Mil-spec forged lower receiver with trigger guard and threaded bolt catch.', - longDescription: 'Aero Precision lowers are known for their tight tolerances and high quality. Perfect for any AR-15 build.', - image_url: 'https://placehold.co/300x200/1f2937/ffffff?text=Lower+Receiver', - images: [ - 'https://placehold.co/600x400/1f2937/ffffff?text=Lower+Receiver+Front', - 'https://placehold.co/600x400/1f2937/ffffff?text=Lower+Receiver+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50/1f2937/ffffff?text=Aero' - }, - category: { - id: 'c7', - name: 'Lower Receiver', - icon: '🧩' - }, - specifications: { - weight: '8.5 oz', - material: '7075-T6 Aluminum', - finish: 'Type III Hardcoat Anodized', - compatibility: ['AR-15'] - }, - restrictions: {}, - offers: [ - { - price: 89.99, - url: 'https://aeroprecisionusa.com/lower', - vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' - }, - inStock: true, - shipping: 'Free shipping' - } - ], - reviews: [], - relatedProducts: ['1', '2', '3'], - compatibility: ['Trigger', 'Magazine'] - }, - { - id: '8', - name: 'Radian Raptor Charging Handle', - description: 'Ambidextrous charging handle with oversized latches and smooth operation.', - longDescription: 'The Radian Raptor is a premium ambidextrous charging handle designed for fast, fluid operation from either side of the rifle.', - image_url: 'https://placehold.co/300x200.png?text=Charging+Handle', - images: [ - 'https://placehold.co/600x400.png?text=Charging+Handle+Front', - 'https://placehold.co/600x400.png?text=Charging+Handle+Side' - ], - brand: { - id: 'b6', - name: 'Radian Weapons', - logo: 'https://placehold.co/100x50.png?text=Radian' - }, - category: { - id: 'c8', - name: 'Charging Handle', - icon: 'πŸ”§' - }, - specifications: { - weight: '1.3 oz', - material: '7075-T6 Aluminum', - finish: 'Type III Hardcoat Anodized', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 89.99, - url: 'https://radianweapons.com/raptor', - vendor: { - name: 'Radian Weapons', - logo: 'https://placehold.co/80x40.png?text=Radian' + logo: '/window.svg' }, inStock: true, shipping: 'Free shipping' @@ -472,119 +397,32 @@ export const mockProducts: Product[] = [ { id: 'r7', rating: 5, - comment: 'Smoothest charging handle I have used.', - user: 'LeftyShooter', - date: '2024-02-15' + comment: 'Excellent handguard. Great heat dissipation and solid mounting.', + user: 'BCM_Fan', + date: '2024-01-16' } ], - relatedProducts: ['2', '7'], - compatibility: ['Upper Receiver'] + relatedProducts: ['1', '2'], + compatibility: ['Upper Receiver', 'Barrel', 'Gas Block'] }, { - id: '9', - name: 'SureFire WarComp Flash Hider', - description: 'Compensator/flash hider hybrid with suppressor mount capability.', - longDescription: 'The SureFire WarComp provides excellent flash reduction and muzzle control, and is compatible with SureFire suppressors.', - image_url: 'https://placehold.co/300x200.png?text=Muzzle+Device', - images: [ - 'https://placehold.co/600x400.png?text=Muzzle+Device+Front', - 'https://placehold.co/600x400.png?text=Muzzle+Device+Side' - ], - brand: { - id: 'b7', - name: 'SureFire', - logo: 'https://placehold.co/100x50.png?text=SureFire' - }, - category: { - id: 'c9', - name: 'Muzzle Device', - icon: 'πŸ’₯' - }, - specifications: { - weight: '2.7 oz', - material: 'Stainless Steel', - finish: 'Black Oxide', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 159.99, - url: 'https://surefire.com/warcomp', - vendor: { - name: 'SureFire', - logo: 'https://placehold.co/80x40.png?text=SureFire' - }, - inStock: true, - shipping: 'Free shipping' - } - ], - reviews: [], - relatedProducts: ['3'], - compatibility: ['Barrel', 'Suppressor'] - }, - { - id: '10', - name: 'Toolcraft BCG - Nitride', - description: 'Mil-spec bolt carrier group with Carpenter 158 bolt and nitride finish.', - longDescription: 'Toolcraft BCGs are trusted by professionals for their reliability and durability. Nitride finish for easy cleaning and corrosion resistance.', - image_url: 'https://placehold.co/300x200.png?text=BCG', - images: [ - 'https://placehold.co/600x400.png?text=BCG+Front', - 'https://placehold.co/600x400.png?text=BCG+Side' - ], - brand: { - id: 'b8', - name: 'Toolcraft', - logo: 'https://placehold.co/100x50.png?text=Toolcraft' - }, - category: { - id: 'c10', - name: 'BCG', - icon: 'πŸ”©' - }, - specifications: { - weight: '11.5 oz', - material: '8620 Steel', - finish: 'Nitride', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 149.99, - url: 'https://wctoolcraft.com/bcg-nitride', - vendor: { - name: 'WC Toolcraft', - logo: 'https://placehold.co/80x40.png?text=Toolcraft' - }, - inStock: true, - shipping: 'Free shipping' - } - ], - reviews: [], - relatedProducts: ['2', '7'], - compatibility: ['Upper Receiver', 'Barrel'] - }, - { - id: '11', + id: '7', name: 'Aero Precision Gas Block - Low Profile', description: 'Low-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', + longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds. Features an adjustment screw for fine-tuning gas flow.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b3', name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' + logo: '/window.svg' }, category: { - id: 'c11', - name: 'Gas Block', - icon: 'πŸ›‘' + id: 'c7', + name: 'Gas Block' }, specifications: { weight: '1.1 oz', @@ -592,14 +430,19 @@ export const mockProducts: Product[] = [ finish: 'Phosphate', compatibility: ['AR-15', 'M4'] }, - restrictions: {}, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, offers: [ { price: 49.99, url: 'https://aeroprecisionusa.com/gas-block', vendor: { name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' + logo: '/window.svg' }, inStock: true, shipping: 'Flat rate $7' @@ -610,598 +453,520 @@ export const mockProducts: Product[] = [ compatibility: ['Barrel', 'Handguard'] }, { - id: '12', + id: '8', name: 'BCM Gas Tube - Mid Length', description: 'Stainless steel gas tube for mid-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', + longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels. Features proper crimping and heat treatment.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' + '/window.svg', + '/window.svg' ], brand: { id: 'b2', name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' + logo: '/window.svg' }, category: { - id: 'c12', - name: 'Gas Tube', - icon: 'πŸ§ͺ' + id: 'c8', + name: 'Gas Tube' }, specifications: { weight: '0.7 oz', material: 'Stainless Steel', compatibility: ['AR-15', 'M4'] }, - restrictions: {}, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, offers: [ { price: 19.99, url: 'https://bravocompanyusa.com/gas-tube', vendor: { name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' + logo: '/window.svg' }, inStock: true, shipping: 'Flat rate $5' } ], reviews: [], - relatedProducts: ['11'], + relatedProducts: ['7'], compatibility: ['Gas Block'] }, { - id: '13', - name: 'Aero Precision Gas Block - High Profile', - description: 'High-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', + id: '9', + name: 'A2 Flash Hider', + description: 'Standard A2 flash hider for AR-15 rifles.', + longDescription: 'The A2 flash hider is the standard muzzle device for AR-15 rifles. Provides effective flash suppression and is compatible with most AR-15 barrels.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b6', + name: 'Mil-Spec', + logo: '/window.svg' + }, + category: { + id: 'c9', + name: 'Muzzle Device' + }, + specifications: { + weight: '2.1 oz', + material: 'Steel', + finish: 'Phosphate', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 15.99, + url: 'https://primaryarms.com/a2-flash-hider', + vendor: { + name: 'Primary Arms', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping on orders over $150' + } + ], + reviews: [], + relatedProducts: ['1'], + compatibility: ['Barrel'] + }, + { + id: '10', + name: 'Aero Precision Lower Receiver', + description: 'Forged lower receiver with enhanced features.', + longDescription: 'The Aero Precision lower receiver features enhanced trigger guard, threaded bolt catch roll pin, and tension screw. Made from 7075-T6 aluminum with Type III hardcoat anodizing.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' ], brand: { id: 'b3', name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' + logo: '/window.svg' }, category: { - id: 'c13', - name: 'Gas Block', - icon: 'πŸ›‘' + id: 'c10', + name: 'Lower Receiver' }, specifications: { - weight: '1.2 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] + weight: '0.5 lbs', + material: '7075-T6 Aluminum', + finish: 'Type III Hardcoat Anodized', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, - restrictions: {}, offers: [ { - price: 59.99, - url: 'https://aeroprecisionusa.com/gas-block', + price: 89.99, + url: 'https://aeroprecisionusa.com/lower-receiver', vendor: { name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' + logo: '/window.svg' }, inStock: true, shipping: 'Flat rate $7' } ], reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] + relatedProducts: ['11', '12', '13'], + compatibility: ['Trigger', 'Pistol Grip', 'Stock', 'Buffer Tube'] }, { - id: '14', - name: 'BCM Gas Tube - Full Length', - description: 'Stainless steel gas tube for full-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', + id: '11', + name: 'LaRue MBT-2S Trigger', + description: 'Two-stage trigger with excellent break and reset.', + longDescription: 'The LaRue MBT-2S features a crisp two-stage design with a 2.5 lb second stage. Made from tool steel and includes both curved and straight shoe options.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b7', + name: 'LaRue Tactical', + logo: '/window.svg' + }, + category: { + id: 'c11', + name: 'Trigger' + }, + specifications: { + weight: '2.1 oz', + material: 'Tool Steel', + finish: 'Nitride', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 99.99, + url: 'https://larue.com/mbt-2s-trigger', + vendor: { + name: 'LaRue Tactical', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping' + } + ], + reviews: [], + relatedProducts: ['10'], + compatibility: ['Lower Receiver'] + }, + { + id: '12', + name: 'BCM Gunfighter Pistol Grip', + description: 'Ergonomic pistol grip with enhanced texture.', + longDescription: 'The BCM Gunfighter pistol grip features an ergonomic design with enhanced texture for secure grip. Includes a storage compartment and is compatible with standard AR-15 lowers.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' ], brand: { id: 'b2', name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' + logo: '/window.svg' + }, + category: { + id: 'c12', + name: 'Pistol Grip' + }, + specifications: { + weight: '2.8 oz', + material: 'Polymer', + finish: 'Molded', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 24.99, + url: 'https://bravocompanyusa.com/gunfighter-grip', + vendor: { + name: 'BCM', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping' + } + ], + reviews: [], + relatedProducts: ['10'], + compatibility: ['Lower Receiver'] + }, + { + id: '13', + name: 'BCM Buffer Tube', + description: 'Mil-spec buffer tube for AR-15 rifles.', + longDescription: 'The BCM buffer tube is made to mil-spec dimensions and features proper staking for the castle nut. Compatible with all mil-spec stocks and buffer assemblies.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b2', + name: 'BCM', + logo: '/window.svg' + }, + category: { + id: 'c13', + name: 'Buffer Tube' + }, + specifications: { + weight: '3.2 oz', + material: '7075-T6 Aluminum', + finish: 'Type III Hardcoat Anodized', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 39.99, + url: 'https://bravocompanyusa.com/buffer-tube', + vendor: { + name: 'BCM', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping' + } + ], + reviews: [], + relatedProducts: ['14', '15'], + compatibility: ['Lower Receiver', 'Stock', 'Buffer'] + }, + { + id: '14', + name: 'H2 Buffer', + description: 'Heavy buffer for reduced recoil and improved cycling.', + longDescription: 'The H2 buffer provides additional weight to reduce felt recoil and improve cycling reliability. Compatible with all mil-spec buffer tubes.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b8', + name: 'Mil-Spec', + logo: '/window.svg' }, category: { id: 'c14', - name: 'Gas Tube', - icon: 'πŸ§ͺ' + name: 'Buffer' + }, + specifications: { + weight: '4.6 oz', + material: 'Steel', + finish: 'Phosphate', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 19.99, + url: 'https://primaryarms.com/h2-buffer', + vendor: { + name: 'Primary Arms', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping on orders over $150' + } + ], + reviews: [], + relatedProducts: ['13', '15'], + compatibility: ['Buffer Tube', 'Buffer Spring'] + }, + { + id: '15', + name: 'Buffer Spring', + description: 'Mil-spec buffer spring for AR-15 rifles.', + longDescription: 'The mil-spec buffer spring provides reliable cycling and is compatible with all standard buffer assemblies. Made from high-quality spring steel.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b8', + name: 'Mil-Spec', + logo: '/window.svg' + }, + category: { + id: 'c15', + name: 'Buffer Spring' }, specifications: { weight: '0.8 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] + material: 'Spring Steel', + finish: 'Natural', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, - restrictions: {}, offers: [ { - price: 29.99, - url: 'https://bravocompanyusa.com/gas-tube', + price: 9.99, + url: 'https://primaryarms.com/buffer-spring', vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' + name: 'Primary Arms', + logo: '/window.svg' }, inStock: true, - shipping: 'Flat rate $5' + shipping: 'Free shipping on orders over $150' + } + ], + reviews: [], + relatedProducts: ['13', '14'], + compatibility: ['Buffer Tube', 'Buffer'] + }, + { + id: '16', + name: 'BCM Gunfighter Stock', + description: 'Lightweight collapsible stock with enhanced ergonomics.', + longDescription: 'The BCM Gunfighter stock features a lightweight design with enhanced cheek weld and secure locking mechanism. Compatible with all mil-spec buffer tubes.', + image_url: '/window.svg', + images: [ + '/window.svg', + '/window.svg' + ], + brand: { + id: 'b2', + name: 'BCM', + logo: '/window.svg' + }, + category: { + id: 'c16', + name: 'Stock' + }, + specifications: { + weight: '6.8 oz', + material: 'Polymer / Aluminum', + finish: 'Molded / Anodized', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] + }, + offers: [ + { + price: 59.99, + url: 'https://bravocompanyusa.com/gunfighter-stock', + vendor: { + name: 'BCM', + logo: '/window.svg' + }, + inStock: true, + shipping: 'Free shipping' } ], reviews: [], relatedProducts: ['13'], - compatibility: ['Gas Block'] - }, - { - id: '15', - name: 'Aero Precision Gas Block - Low Profile', - description: 'Low-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' - }, - category: { - id: 'c15', - name: 'Gas Block', - icon: 'πŸ›‘' - }, - specifications: { - weight: '1.1 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 49.99, - url: 'https://aeroprecisionusa.com/gas-block', - vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' - }, - inStock: true, - shipping: 'Flat rate $7' - } - ], - reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] - }, - { - id: '16', - name: 'BCM Gas Tube - Mid Length', - description: 'Stainless steel gas tube for mid-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' - ], - brand: { - id: 'b2', - name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' - }, - category: { - id: 'c16', - name: 'Gas Tube', - icon: 'πŸ§ͺ' - }, - specifications: { - weight: '0.7 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 19.99, - url: 'https://bravocompanyusa.com/gas-tube', - vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' - }, - inStock: true, - shipping: 'Flat rate $5' - } - ], - reviews: [], - relatedProducts: ['15'], - compatibility: ['Gas Block'] + compatibility: ['Buffer Tube'] }, { id: '17', - name: 'Aero Precision Gas Block - High Profile', - description: 'High-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', + name: 'Magpul PMAG 30 Gen 3', + description: '30-round polymer magazine with enhanced reliability.', + longDescription: 'The Magpul PMAG Gen 3 features enhanced feed lips, improved follower design, and over-insertion protection. Made from high-strength polymer with steel reinforcement.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' + '/window.svg', + '/window.svg' ], brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' + id: 'b9', + name: 'Magpul', + logo: '/window.svg' }, category: { id: 'c17', - name: 'Gas Block', - icon: 'πŸ›‘' + name: 'Magazine' }, specifications: { - weight: '1.2 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] + weight: '4.2 oz', + capacity: '30 rounds', + material: 'Polymer', + finish: 'Molded', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: ['CA', 'CO', 'CT', 'HI', 'MD', 'MA', 'NJ', 'NY', 'WA'] }, - restrictions: {}, offers: [ { - price: 59.99, - url: 'https://aeroprecisionusa.com/gas-block', + price: 14.99, + url: 'https://primaryarms.com/pmag-gen3', vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' + name: 'Primary Arms', + logo: '/window.svg' }, inStock: true, - shipping: 'Flat rate $7' + shipping: 'Free shipping on orders over $150' } ], reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] + relatedProducts: ['10'], + compatibility: ['Lower Receiver'] }, { id: '18', - name: 'BCM Gas Tube - Full Length', - description: 'Stainless steel gas tube for full-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', + name: 'Magpul MBUS Pro Sights', + description: 'Low-profile flip-up backup sights.', + longDescription: 'The Magpul MBUS Pro sights feature a low-profile design that flips up when needed. Made from steel with a durable finish for long-term reliability.', + image_url: '/window.svg', images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' + '/window.svg', + '/window.svg' ], brand: { - id: 'b2', - name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' + id: 'b9', + name: 'Magpul', + logo: '/window.svg' }, category: { id: 'c18', - name: 'Gas Tube', - icon: 'πŸ§ͺ' + name: 'Sights' }, specifications: { - weight: '0.8 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 29.99, - url: 'https://bravocompanyusa.com/gas-tube', - vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' - }, - inStock: true, - shipping: 'Flat rate $5' - } - ], - reviews: [], - relatedProducts: ['17'], - compatibility: ['Gas Block'] - }, - { - id: '19', - name: 'Aero Precision Gas Block - Low Profile', - description: 'Low-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' - }, - category: { - id: 'c19', - name: 'Gas Block', - icon: 'πŸ›‘' - }, - specifications: { - weight: '1.1 oz', + weight: '2.1 oz (front), 2.3 oz (rear)', material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] + finish: 'Nitride', + compatibility: ['AR-15', 'M4', 'M16'] + }, + restrictions: { + nfa: false, + sbr: false, + suppressor: false, + stateRestrictions: [] }, - restrictions: {}, offers: [ { - price: 49.99, - url: 'https://aeroprecisionusa.com/gas-block', + price: 149.99, + url: 'https://primaryarms.com/mbus-pro-sights', vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' + name: 'Primary Arms', + logo: '/window.svg' }, inStock: true, - shipping: 'Flat rate $7' + shipping: 'Free shipping on orders over $150' } ], reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] - }, - { - id: '20', - name: 'BCM Gas Tube - Mid Length', - description: 'Stainless steel gas tube for mid-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' - ], - brand: { - id: 'b2', - name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' - }, - category: { - id: 'c20', - name: 'Gas Tube', - icon: 'πŸ§ͺ' - }, - specifications: { - weight: '0.7 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 19.99, - url: 'https://bravocompanyusa.com/gas-tube', - vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' - }, - inStock: true, - shipping: 'Flat rate $5' - } - ], - reviews: [], - relatedProducts: ['19'], - compatibility: ['Gas Block'] - }, - { - id: '21', - name: 'Aero Precision Gas Block - High Profile', - description: 'High-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' - }, - category: { - id: 'c21', - name: 'Gas Block', - icon: 'πŸ›‘' - }, - specifications: { - weight: '1.2 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 59.99, - url: 'https://aeroprecisionusa.com/gas-block', - vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' - }, - inStock: true, - shipping: 'Flat rate $7' - } - ], - reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] - }, - { - id: '22', - name: 'BCM Gas Tube - Full Length', - description: 'Stainless steel gas tube for full-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' - ], - brand: { - id: 'b2', - name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' - }, - category: { - id: 'c22', - name: 'Gas Tube', - icon: 'πŸ§ͺ' - }, - specifications: { - weight: '0.8 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 29.99, - url: 'https://bravocompanyusa.com/gas-tube', - vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' - }, - inStock: true, - shipping: 'Flat rate $5' - } - ], - reviews: [], - relatedProducts: ['21'], - compatibility: ['Gas Block'] - }, - { - id: '23', - name: 'Aero Precision Gas Block - Low Profile', - description: 'Low-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' - }, - category: { - id: 'c23', - name: 'Gas Block', - icon: 'πŸ›‘' - }, - specifications: { - weight: '1.1 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 49.99, - url: 'https://aeroprecisionusa.com/gas-block', - vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' - }, - inStock: true, - shipping: 'Flat rate $7' - } - ], - reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] - }, - { - id: '24', - name: 'BCM Gas Tube - Mid Length', - description: 'Stainless steel gas tube for mid-length gas systems.', - longDescription: 'BCM gas tubes are made from high-quality stainless steel and are compatible with most AR-15 barrels.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Tube', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Tube+Front', - 'https://placehold.co/600x400.png?text=Gas+Tube+Side' - ], - brand: { - id: 'b2', - name: 'BCM', - logo: 'https://placehold.co/100x50.png?text=BCM' - }, - category: { - id: 'c24', - name: 'Gas Tube', - icon: 'πŸ§ͺ' - }, - specifications: { - weight: '0.7 oz', - material: 'Stainless Steel', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 19.99, - url: 'https://bravocompanyusa.com/gas-tube', - vendor: { - name: 'BCM', - logo: 'https://placehold.co/80x40.png?text=BCM' - }, - inStock: true, - shipping: 'Flat rate $5' - } - ], - reviews: [], - relatedProducts: ['23'], - compatibility: ['Gas Block'] - }, - { - id: '25', - name: 'Aero Precision Gas Block - High Profile', - description: 'High-profile adjustable gas block for free-float handguards.', - longDescription: 'Aero Precision gas blocks are machined to tight tolerances and are perfect for custom AR builds.', - image_url: 'https://placehold.co/300x200.png?text=Gas+Block', - images: [ - 'https://placehold.co/600x400.png?text=Gas+Block+Front', - 'https://placehold.co/600x400.png?text=Gas+Block+Side' - ], - brand: { - id: 'b3', - name: 'Aero Precision', - logo: 'https://placehold.co/100x50.png?text=Aero' - }, - category: { - id: 'c25', - name: 'Gas Block', - icon: 'πŸ›‘' - }, - specifications: { - weight: '1.2 oz', - material: 'Steel', - finish: 'Phosphate', - compatibility: ['AR-15', 'M4'] - }, - restrictions: {}, - offers: [ - { - price: 59.99, - url: 'https://aeroprecisionusa.com/gas-block', - vendor: { - name: 'Aero Precision', - logo: 'https://placehold.co/80x40.png?text=Aero' - }, - inStock: true, - shipping: 'Flat rate $7' - } - ], - reviews: [], - relatedProducts: ['1', '2'], - compatibility: ['Barrel', 'Handguard'] + relatedProducts: ['6'], + compatibility: ['Handguard', 'Upper Receiver'] } -]; \ No newline at end of file +]; \ No newline at end of file diff --git a/src/store/useBuildStore.ts b/src/store/useBuildStore.ts new file mode 100644 index 0000000..6cbe72a --- /dev/null +++ b/src/store/useBuildStore.ts @@ -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; // key: checklist component id + selectPartForComponent: (componentId: string, part: BuildPart) => void; + removePartForComponent: (componentId: string) => void; + clearBuild: () => void; +} + +const buildStoreCreator: StateCreator = (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()( + persist(buildStoreCreator, { + name: 'current-build-storage', + }) +); \ No newline at end of file From 41e55404bf0c38cf5639fd81a6e8264cd155d7a8 Mon Sep 17 00:00:00 2001 From: Sean S Date: Mon, 30 Jun 2025 06:36:03 -0400 Subject: [PATCH 2/2] nextauth working. --- package-lock.json | 237 +++++++++++++++++++++++ package.json | 2 + src/app/account/forgot-password/page.tsx | 46 +++++ src/app/account/layout.tsx | 43 ++++ src/app/account/login/page.tsx | 168 ++++++++++++++++ src/app/account/profile/page.tsx | 34 ++++ src/app/account/register/page.tsx | 94 +++++++++ src/app/api/auth/[...nextauth]/route.ts | 70 +++++++ src/app/api/register/route.ts | 27 +++ src/app/globals.css | 3 - src/app/layout.tsx | 12 +- src/app/parts/page.tsx | 5 +- src/components/AuthProvider.tsx | 17 ++ src/components/Navbar.tsx | 127 +++++++++++- src/components/NavigationWrapper.tsx | 15 ++ src/components/ProductCard.tsx | 11 +- src/components/Providers.tsx | 21 ++ src/store/useAuthStore.ts | 16 ++ 18 files changed, 925 insertions(+), 23 deletions(-) create mode 100644 src/app/account/forgot-password/page.tsx create mode 100644 src/app/account/layout.tsx create mode 100644 src/app/account/login/page.tsx create mode 100644 src/app/account/profile/page.tsx create mode 100644 src/app/account/register/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/register/route.ts create mode 100644 src/components/AuthProvider.tsx create mode 100644 src/components/NavigationWrapper.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/store/useAuthStore.ts diff --git a/package-lock.json b/package-lock.json index d5d9c8a..a0699c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "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", "zustand": "^5.0.6" @@ -42,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", @@ -1049,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", @@ -1224,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", @@ -2505,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", @@ -4449,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", @@ -4804,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", @@ -4859,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", @@ -4992,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", @@ -5285,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", @@ -5295,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", @@ -6592,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", @@ -6802,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", diff --git a/package.json b/package.json index 027521e..f3ae57f 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "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", "zustand": "^5.0.6" diff --git a/src/app/account/forgot-password/page.tsx b/src/app/account/forgot-password/page.tsx new file mode 100644 index 0000000..e3d9662 --- /dev/null +++ b/src/app/account/forgot-password/page.tsx @@ -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 ( +
+
+

Forgot your password?

+

+ Enter your email address and we'll send you a link to reset your password.
+ (This feature is not yet implemented.) +

+
+ setEmail(e.target.value)} + disabled={submitted} + /> + + +
+ Back to login +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx new file mode 100644 index 0000000..0664ca6 --- /dev/null +++ b/src/app/account/layout.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link'; + +export default function AccountLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {/* Simple navbar with back button */} + + + {/* Main content */} +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/account/login/page.tsx b/src/app/account/login/page.tsx new file mode 100644 index 0000000..76a17d3 --- /dev/null +++ b/src/app/account/login/page.tsx @@ -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 ( +
+ {/* Left side image or illustration */} +
+ {/* You can replace this with your own image or illustration */} + Login visual +
+ {/* Right side form */} +
+
+
+

Sign in to your account

+

+ Or{' '} + + Sign Up For Free + +

+
+
+ +
+
+ + 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} + /> +
+
+ + 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} + /> +
+
+ + {error && ( +
{error}
+ )} + +
+
+ + +
+ +
+ + Forgot your password? + +
+
+ +
+ +
+ + + {/* Social login buttons */} +
+
+
+
+
+
+ + Or continue with + +
+
+
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/account/profile/page.tsx b/src/app/account/profile/page.tsx new file mode 100644 index 0000000..5f4023d --- /dev/null +++ b/src/app/account/profile/page.tsx @@ -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
Loading...
; + } + + if (!session?.user) { + return null; + } + + return ( +
+

Profile

+
+
Name: {session.user.name || 'N/A'}
+
Email: {session.user.email}
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/account/register/page.tsx b/src/app/account/register/page.tsx new file mode 100644 index 0000000..d95af7e --- /dev/null +++ b/src/app/account/register/page.tsx @@ -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 ( +
+
+

Create your account

+
+ setEmail(e.target.value)} + disabled={loading} + /> +
+ setPassword(e.target.value)} + disabled={loading} + /> + +
+ {error &&
{error}
} + + +
+ Already have an account? Sign in +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..724b52c --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 } \ No newline at end of file diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts new file mode 100644 index 0000000..4b61305 --- /dev/null +++ b/src/app/api/register/route.ts @@ -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 }); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index f559d12..b2c7231 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,9 +42,6 @@ /* Button styles */ /* Removed custom .btn-primary to avoid DaisyUI conflict */ - .btn-secondary { - @apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring; - } /* Input styles */ .input-field { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9d5d7e7..2ceb6e1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"] }); @@ -19,12 +18,9 @@ export default function RootLayout({ return ( - -
- - {children} -
-
+ + {children} + ); diff --git a/src/app/parts/page.tsx b/src/app/parts/page.tsx index 22530a5..eef5d9a 100644 --- a/src/app/parts/page.tsx +++ b/src/app/parts/page.tsx @@ -668,7 +668,7 @@ export default function Home() { } else if (matchingComponent && !selectedParts[matchingComponent.id]) { return ( ); } else { diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..c8c4f49 --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -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}; +} \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2608dec..49c68f4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -3,10 +3,23 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import ThemeSwitcher from './ThemeSwitcher'; -import { MagnifyingGlassIcon, UserCircleIcon } from '@heroicons/react/24/outline'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { useSession, signIn, signOut } from 'next-auth/react'; +import { useState, ReactNode } from 'react'; + +interface MenuItem { + label: string; + href?: string; + active?: boolean; + onClick?: () => void; + custom?: ReactNode; +} export default function Navbar() { const pathname = usePathname(); + const { data: session, status } = useSession(); + const loading = status === 'loading'; + const [menuOpen, setMenuOpen] = useState(false); const navItems = [ { href: '/parts', label: 'Parts Catalog' }, @@ -14,12 +27,114 @@ export default function Navbar() { { href: '/my-builds', label: 'My Builds' }, ]; + // Dropdown menu items + const rawMenuItems = [ + session?.user + ? { + label: 'Profile', + href: '/account/profile', + active: pathname === '/account/profile', + onClick: () => setMenuOpen(false), + } + : undefined, + session?.user + ? { + label: 'Sign Out', + onClick: () => { + setMenuOpen(false); + signOut({ callbackUrl: '/' }); + }, + } + : { + label: 'Sign In', + onClick: () => { + setMenuOpen(false); + signIn(); + }, + }, + { + label: 'Theme', + custom: , + }, + ]; + function isMenuItem(item: unknown): item is MenuItem { + return typeof item === 'object' && item !== null && 'label' in item; + } + const menuItems = rawMenuItems.filter(isMenuItem); + return ( <> {/* Top Bar */} -
+
Pew Builder - +
+ {loading ? null : session?.user ? ( + <> + + {menuOpen && ( +
setMenuOpen(false)} + > +
+ {session?.user && ( +
+ {session.user.email} +
+ )} + {menuItems.map((item, idx) => + item.custom ? ( +
+ Theme + {item.custom} +
+ ) : item.href ? ( + + {item.label} + + ) : ( + + ) + )} +
+
+ )} + + ) : ( + + )} +
{/* Subnav */} @@ -42,15 +157,11 @@ export default function Navbar() { ))}
- {/* Right: Sign In + Search */} + {/* Right: Search */}
- - Sign In - -
diff --git a/src/components/NavigationWrapper.tsx b/src/components/NavigationWrapper.tsx new file mode 100644 index 0000000..99b2080 --- /dev/null +++ b/src/components/NavigationWrapper.tsx @@ -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 ; +} \ No newline at end of file diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index 85f7284..105ba22 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -120,11 +120,18 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps) {onAdd && ( )} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..3ecd704 --- /dev/null +++ b/src/components/Providers.tsx @@ -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 ( + + + +
+ + {children} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts new file mode 100644 index 0000000..c359787 --- /dev/null +++ b/src/store/useAuthStore.ts @@ -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((set) => ({ + session: null, + isLoading: true, + setSession: (session) => set({ session }), + setLoading: (isLoading) => set({ isLoading }), +})); \ No newline at end of file
Product @@ -527,7 +629,9 @@ export default function Home() { 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
-
{part.name}
+ + {part.name} +
{part.brand.name}
@@ -542,11 +646,50 @@ export default function Home() {
- - - Add - - + {(() => { + // 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 ( + + ); + } else if (matchingComponent && !selectedParts[matchingComponent.id]) { + return ( + + ); + } else { + return ( + Part Selected + ); + } + })()}