mirror of
https://gitea.gofwd.group/sean/gunbuilder-next-tailwind.git
synced 2025-12-06 02:56:45 -05:00
fuck yeah. daisyui integrated
This commit is contained in:
105
src/app/daisyui-demo/page.tsx
Normal file
105
src/app/daisyui-demo/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
|
||||
export default function DaisyUIDemo() {
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 py-8">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<h1 className="text-3xl font-bold text-base-content mb-2">DaisyUI Component Demo</h1>
|
||||
<p className="text-base-content/70 mb-6">Test and validate DaisyUI components and theme integration.</p>
|
||||
|
||||
{/* Alerts */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Alerts</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="alert alert-info">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-info text-info-content w-8 h-8 mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span>This is a DaisyUI info alert! 🎉</span>
|
||||
</div>
|
||||
<div className="alert alert-success">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-success text-success-content w-8 h-8 mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>DaisyUI is working perfectly with your theme!</span>
|
||||
</div>
|
||||
<div className="alert alert-warning">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-warning text-warning-content w-8 h-8 mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>This is a warning alert example</span>
|
||||
</div>
|
||||
<div className="alert alert-error">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-error text-error-content w-8 h-8 mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>This is an error alert example</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Buttons */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Buttons</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn btn-primary">Primary</button>
|
||||
<button className="btn btn-secondary">Secondary</button>
|
||||
<button className="btn btn-accent">Accent</button>
|
||||
<button className="btn btn-info">Info</button>
|
||||
<button className="btn btn-success">Success</button>
|
||||
<button className="btn btn-warning">Warning</button>
|
||||
<button className="btn btn-error">Error</button>
|
||||
<button className="btn btn-outline">Outline</button>
|
||||
<button className="btn btn-disabled" disabled>Disabled</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cards */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Cards</h2>
|
||||
<div className="card w-full bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">DaisyUI Card</h2>
|
||||
<p>This is a sample card using DaisyUI's card component.</p>
|
||||
<div className="card-actions justify-end">
|
||||
<button className="btn btn-primary">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Badges */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Badges</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="badge badge-primary">Primary</span>
|
||||
<span className="badge badge-secondary">Secondary</span>
|
||||
<span className="badge badge-accent">Accent</span>
|
||||
<span className="badge badge-info">Info</span>
|
||||
<span className="badge badge-success">Success</span>
|
||||
<span className="badge badge-warning">Warning</span>
|
||||
<span className="badge badge-error">Error</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tooltip */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Tooltip</h2>
|
||||
<Tooltip content="This is a DaisyUI tooltip!">
|
||||
<button className="btn btn-outline">Hover me</button>
|
||||
</Tooltip>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
297
src/app/page.tsx
297
src/app/page.tsx
@@ -5,6 +5,9 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/20/solid';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
import ProductCard from '@/components/ProductCard';
|
||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
|
||||
// Sample firearm parts data
|
||||
const parts = [
|
||||
@@ -704,46 +707,6 @@ const RestrictionBadge = ({ restriction }: { restriction: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Product card component
|
||||
const ProductCard = ({ product }: { product: any }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const lowestPrice = Math.min(...product.offers.map((offer: any) => offer.price));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 border border-gray-200">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-48 object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{product.restrictions && product.restrictions.length > 0 && (
|
||||
<div className="absolute top-2 left-2 flex flex-wrap gap-1">
|
||||
{product.restrictions.map((restriction: string) => (
|
||||
<RestrictionBadge key={restriction} restriction={restriction} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{product.description}</p>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gray-500">{product.brand.name}</span>
|
||||
<span className="text-lg font-bold text-gray-900">${lowestPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{product.category.name}</span>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tailwind UI Dropdown Component
|
||||
const Dropdown = ({
|
||||
label,
|
||||
@@ -762,16 +725,16 @@ const Dropdown = ({
|
||||
<div className="relative">
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative">
|
||||
<Listbox.Label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{label}
|
||||
</Listbox.Label>
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-2 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
||||
<span className="block truncate text-neutral-900 dark:text-white">
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
@@ -781,7 +744,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 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<Listbox.Options>
|
||||
{options.map((option, optionIdx) => (
|
||||
@@ -789,7 +752,7 @@ const Dropdown = ({
|
||||
key={optionIdx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-blue-100 text-blue-900' : 'text-gray-900'
|
||||
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
||||
}`
|
||||
}
|
||||
value={option}
|
||||
@@ -800,7 +763,7 @@ const Dropdown = ({
|
||||
{option}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -826,6 +789,7 @@ export default function Home() {
|
||||
const [selectedRestriction, setSelectedRestriction] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||
|
||||
// Read category from URL parameter on page load
|
||||
useEffect(() => {
|
||||
@@ -1078,104 +1042,159 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parts Table */}
|
||||
{/* Parts Display */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Name</span>
|
||||
<span className="text-sm">{getSortIcon('name')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Brand
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
||||
onClick={() => handleSort('price')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Price</span>
|
||||
<span className="text-sm">{getSortIcon('price')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{sortedParts.map((part) => (
|
||||
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||
{part.category.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
{part.name}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-neutral-900 dark:text-white">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
{part.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button className="btn-primary text-xs">
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* View Toggle and Results Count */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Showing {sortedParts.length} of {parts.length} parts
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Showing {sortedParts.length} of {parts.length} parts
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
||||
</div>
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
||||
onClick={() => setViewMode('cards')}
|
||||
>
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restriction Alert Example */}
|
||||
{sortedParts.some(part => part.restrictions.includes('NFA')) && (
|
||||
<div className="mb-6">
|
||||
<RestrictionAlert
|
||||
type="warning"
|
||||
title="NFA Items Detected"
|
||||
message="Some items in your search require National Firearms Act registration. Please ensure compliance with all federal and state regulations."
|
||||
icon="🔒"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table View */}
|
||||
{viewMode === 'table' && (
|
||||
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Name</span>
|
||||
<span className="text-sm">{getSortIcon('name')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Brand
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
||||
onClick={() => handleSort('price')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Price</span>
|
||||
<span className="text-sm">{getSortIcon('price')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{sortedParts.map((part) => (
|
||||
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||
{part.category.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
{part.name}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-neutral-900 dark:text-white">
|
||||
{part.brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
{part.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button className="btn-primary text-xs">
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Showing {sortedParts.length} of {parts.length} parts
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card View */}
|
||||
{viewMode === 'cards' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{sortedParts.map((part) => (
|
||||
<ProductCard key={part.id} product={part} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact Restriction Legend */}
|
||||
|
||||
119
src/components/ProductCard.tsx
Normal file
119
src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
brand: { name: string };
|
||||
category: { name: string };
|
||||
restrictions: string[];
|
||||
offers: Array<{ price: number; vendor: { name: string } }>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const lowestPrice = Math.min(...product.offers.map(offer => offer.price));
|
||||
|
||||
// Restriction badge component
|
||||
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
|
||||
const restrictionConfig = {
|
||||
NFA: {
|
||||
label: 'NFA',
|
||||
color: 'badge-error',
|
||||
icon: '🔒',
|
||||
tooltip: 'National Firearms Act - Requires special registration'
|
||||
},
|
||||
SBR: {
|
||||
label: 'SBR',
|
||||
color: 'badge-warning',
|
||||
icon: '📏',
|
||||
tooltip: 'Short Barrel Rifle - Requires NFA registration'
|
||||
},
|
||||
SUPPRESSOR: {
|
||||
label: 'Suppressor',
|
||||
color: 'badge-secondary',
|
||||
icon: '🔇',
|
||||
tooltip: 'Sound Suppressor - Requires NFA registration'
|
||||
},
|
||||
FFL_REQUIRED: {
|
||||
label: 'FFL',
|
||||
color: 'badge-info',
|
||||
icon: '🏪',
|
||||
tooltip: 'Federal Firearms License required for purchase'
|
||||
},
|
||||
STATE_RESTRICTIONS: {
|
||||
label: 'State',
|
||||
color: 'badge-warning',
|
||||
icon: '🗺️',
|
||||
tooltip: 'State-specific restrictions may apply'
|
||||
},
|
||||
HIGH_CAPACITY: {
|
||||
label: 'High Cap',
|
||||
color: 'badge-accent',
|
||||
icon: '🥁',
|
||||
tooltip: 'High capacity magazine - check local laws'
|
||||
},
|
||||
SILENCERSHOP_PARTNER: {
|
||||
label: 'SilencerShop',
|
||||
color: 'badge-success',
|
||||
icon: '🤝',
|
||||
tooltip: 'Available through SilencerShop partnership'
|
||||
}
|
||||
};
|
||||
|
||||
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`badge ${config.color} gap-1 cursor-help`}
|
||||
title={config.tooltip}
|
||||
>
|
||||
<span>{config.icon}</span>
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
|
||||
<figure className="relative">
|
||||
<img
|
||||
src={imageError ? 'https://placehold.co/300x200/6b7280/ffffff?text=No+Image' : product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-48 object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{product.restrictions && product.restrictions.length > 0 && (
|
||||
<div className="absolute top-2 left-2 flex flex-wrap gap-1">
|
||||
{product.restrictions.map((restriction) => (
|
||||
<RestrictionBadge key={restriction} restriction={restriction} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-base-content line-clamp-2">{product.name}</h3>
|
||||
<p className="text-base-content/70 text-sm line-clamp-2">{product.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-base-content/60">{product.brand.name}</span>
|
||||
<span className="text-lg font-bold text-primary">${lowestPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-base-content/50">{product.category.name}</span>
|
||||
<button className="btn btn-primary btn-sm">
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/RestrictionAlert.tsx
Normal file
43
src/components/RestrictionAlert.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
interface RestrictionAlertProps {
|
||||
type: 'warning' | 'error' | 'info' | 'success';
|
||||
title: string;
|
||||
message: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default function RestrictionAlert({ type, title, message, icon }: RestrictionAlertProps) {
|
||||
const alertConfig = {
|
||||
warning: {
|
||||
className: 'alert alert-warning',
|
||||
icon: icon || '⚠️'
|
||||
},
|
||||
error: {
|
||||
className: 'alert alert-error',
|
||||
icon: icon || '🚫'
|
||||
},
|
||||
info: {
|
||||
className: 'alert alert-info',
|
||||
icon: icon || 'ℹ️'
|
||||
},
|
||||
success: {
|
||||
className: 'alert alert-success',
|
||||
icon: icon || '✅'
|
||||
}
|
||||
};
|
||||
|
||||
const config = alertConfig[type];
|
||||
|
||||
return (
|
||||
<div className={config.className}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{config.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<div className="text-sm">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/Tooltip.tsx
Normal file
16
src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Tooltip({ content, children, position = 'top', className = '' }: TooltipProps) {
|
||||
return (
|
||||
<div className={`tooltip tooltip-${position} ${className}`} data-tip={content}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user