chris 84ab6bef2d feat: featured items — admin toggle, badge, sorted to top
- Add featured to ItemOverride so it can be set per-item in admin
- Catalog API applies the override and sorts featured items before
  non-featured (within each group, sortOrder still applies)
- ProductCard shows a teal Featured badge on the image when featured
  and not sold out
- Admin item editor has a  Featured checkbox beside Hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:21:33 -04:00

126 lines
4.3 KiB
TypeScript

'use client'
import { useState } from 'react'
import type { CatalogItem } from '@/data/mock-catalog'
import ColorPicker from './ColorPicker'
import { fmt } from '@/lib/format'
interface Props {
item: CatalogItem
}
function maxColorsFor(name: string): number | null {
const n = name.toLowerCase()
if (/arch|column/.test(n)) return 4
if (/\b11["''″]|\b11[- ]?inch/.test(n)) return 1
if (/number.{0,10}sculpt|sculpt.{0,10}number/.test(n)) return 4
if (/ultimate/.test(n)) return 4
return null
}
/** Lowest stock count across tracked variations. null = nothing tracked. */
function lowestTrackedInventory(item: CatalogItem): number | null {
const tracked = item.variations.filter((v) => v.inventory !== null)
if (!tracked.length) return null
return Math.min(...tracked.map((v) => v.inventory as number))
}
const LOW_STOCK_THRESHOLD = 5
export default function ProductCard({ item }: Props) {
const [showPicker, setShowPicker] = useState(false)
// Prefer admin-set colorMax; fall back to name-based heuristic
const maxColors = item.showColors
? (item.colorMax !== null && item.colorMax !== undefined ? item.colorMax : maxColorsFor(item.name))
: null
const stock = lowestTrackedInventory(item)
const soldOut = stock !== null && stock <= 0
const lowStock = stock !== null && stock > 0 && stock <= LOW_STOCK_THRESHOLD
const priceDisplay = item.price ? `From ${fmt(item.price)}` : 'Custom quote'
return (
<>
<div
className="product-card"
style={{ cursor: soldOut ? 'default' : 'pointer', opacity: soldOut ? 0.65 : 1 }}
onClick={() => !soldOut && setShowPicker(true)}
>
{/* Image */}
<div className="card-image" style={{ position: 'relative' }}>
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt={item.name} />
) : (
<div className="no-image">🎈</div>
)}
{item.featured && !soldOut && (
<span style={{
position: 'absolute', top: 10, left: 10,
background: '#11b3be', color: '#fff',
fontWeight: 700, fontSize: '0.72rem', letterSpacing: '0.05em',
padding: '3px 10px', borderRadius: 4,
textTransform: 'uppercase',
}}>Featured</span>
)}
{soldOut && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.35)',
}}>
<span style={{
background: '#c0392b', color: '#fff',
fontWeight: 700, fontSize: '1rem', letterSpacing: '0.05em',
padding: '6px 18px', borderRadius: 4,
textTransform: 'uppercase',
}}>Sold Out</span>
</div>
)}
</div>
{/* Content */}
<div className="card-content">
<p className="title is-5">{item.name}</p>
<p className="subtitle is-6">{priceDisplay}</p>
{lowStock && (
<p style={{ fontSize: '0.78rem', color: '#c07000', fontWeight: 600, marginBottom: '0.35rem' }}>
Only {stock} left
</p>
)}
<p className="is-size-7">{item.description}</p>
{item.tags.length > 0 && (
<div className="card-tags">
{item.tags.slice(0, 3).map((tag) => (
<span key={tag} className="product-tag">{tag}</span>
))}
</div>
)}
</div>
{/* Footer CTA */}
<footer className="card-footer" style={{ padding: '0.75rem' }}>
<button
className="button is-info is-fullwidth"
style={{ fontWeight: 'bold', fontSize: '1rem' }}
disabled={soldOut}
onClick={(e) => { e.stopPropagation(); if (!soldOut) setShowPicker(true) }}
>
{soldOut ? 'Sold Out' : 'Customize & Order'}
</button>
</footer>
</div>
{showPicker && (
<ColorPicker
product={item}
maxColors={maxColors}
onClose={() => setShowPicker(false)}
/>
)}
</>
)
}