feat: admin color availability filter per item

- Add disabledColors field to ItemOverride and CatalogItem
- Propagate through catalog API applyOverrides
- ColorPicker filters disabled colors out before showing to customers
- New AdminColorFilter modal: same collapsible family layout and balloon
  swatches as the customer view; click to hide/show individual colors;
  Enable all / Disable all shortcuts; badge shows count of hidden colors
- Button appears in the color limits section for color-enabled items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-16 09:00:32 -04:00
parent 1861e10d6d
commit e95ec68931
6 changed files with 267 additions and 2 deletions

View File

@ -7,6 +7,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import type { ItemOverride } from '@/lib/overrides' import type { ItemOverride } from '@/lib/overrides'
import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config' import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config'
import type { HoursConfig, DayHours } from '@/lib/hours-config' import type { HoursConfig, DayHours } from '@/lib/hours-config'
import AdminColorFilter from '@/components/AdminColorFilter'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -753,6 +754,8 @@ function ItemEditor({
const [chromeSurcharge, setChromeSurcharge] = useState<string>( const [chromeSurcharge, setChromeSurcharge] = useState<string>(
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : '' ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : ''
) )
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
const [showColorFilter, setShowColorFilter] = useState(false)
const [quantityUnit, setQuantityUnit] = useState<string>(ov.quantityUnit ?? '') const [quantityUnit, setQuantityUnit] = useState<string>(ov.quantityUnit ?? '')
// Create category // Create category
@ -789,6 +792,7 @@ function ItemEditor({
if (colorMin !== '') patch.colorMin = Number(colorMin) if (colorMin !== '') patch.colorMin = Number(colorMin)
if (colorMax !== '') patch.colorMax = Number(colorMax) if (colorMax !== '') patch.colorMax = Number(colorMax)
if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100) if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100)
patch.disabledColors = disabledColors.length ? disabledColors : undefined
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
else patch.quantityUnit = undefined else patch.quantityUnit = undefined
@ -1177,7 +1181,35 @@ function ItemEditor({
Each chrome color selected adds this amount. Leave blank (or 0) to use a flat "Chrome" Square variation instead. Each chrome color selected adds this amount. Leave blank (or 0) to use a flat "Chrome" Square variation instead.
{item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`} {item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`}
</p> </p>
{/* Color availability */}
<div style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="button is-small is-light"
onClick={() => setShowColorFilter(true)}
>
🎨 Manage available colors
{disabledColors.length > 0 && (
<span style={{
marginLeft: 6, background: '#c07000', color: '#fff',
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
padding: '1px 7px',
}}>
{disabledColors.length} hidden
</span>
)}
</button>
</div> </div>
</div>
)}
{showColorFilter && (
<AdminColorFilter
disabledColors={disabledColors}
onSave={setDisabledColors}
onClose={() => setShowColorFilter(false)}
/>
)} )}
{/* Quantity unit */} {/* Quantity unit */}

View File

@ -18,6 +18,7 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
colorMin: ov.colorMin ?? item.colorMin, colorMin: ov.colorMin ?? item.colorMin,
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
quantityUnit: ov.quantityUnit ?? item.quantityUnit, quantityUnit: ov.quantityUnit ?? item.quantityUnit,
description: ov.descriptionOverride ?? item.description, description: ov.descriptionOverride ?? item.description,
variations: item.variations variations: item.variations

View File

@ -0,0 +1,221 @@
'use client'
import { useState, useEffect } from 'react'
import { BASE } from '@/lib/basepath'
interface ColorEntry {
name: string
hex: string
metallic?: boolean
pearlType?: string
chromeType?: string
image?: string
}
interface ColorFamily {
family: string
colors: ColorEntry[]
}
interface Props {
disabledColors: string[]
onSave: (disabled: string[]) => void
onClose: () => void
}
export default function AdminColorFilter({ disabledColors, onSave, onClose }: Props) {
const [families, setFamilies] = useState<ColorFamily[]>([])
const [disabled, setDisabled] = useState<Set<string>>(() => new Set(disabledColors))
const [openFamily, setOpenFamily] = useState<string | null>(null)
useEffect(() => {
fetch(BASE + '/colors.json')
.then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data))
}, [])
const toggle = (name: string) =>
setDisabled((prev) => {
const next = new Set(prev)
next.has(name) ? next.delete(name) : next.add(name)
return next
})
const disabledCount = disabled.size
const totalColors = families.reduce((n, f) => n + f.colors.length, 0)
return (
<div className="modal is-active" onClick={onClose}>
<div
className="modal-card"
style={{ maxWidth: 680, width: '95vw' }}
onClick={(e) => e.stopPropagation()}
>
<header className="modal-card-head" style={{ background: '#11b3be', gap: '0.75rem' }}>
<p className="modal-card-title has-text-white" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Available Colors
</p>
<button className="delete" aria-label="close" onClick={onClose} style={{ flexShrink: 0 }} />
</header>
<section className="modal-card-body" style={{ maxHeight: '70vh', overflowY: 'auto', padding: '1rem 1.25rem' }}>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.75rem' }}>
Click a color to hide it from customers ordering this item.{' '}
{disabledCount > 0
? <strong style={{ color: '#c07000' }}>{disabledCount} of {totalColors} hidden.</strong>
: <span>All {totalColors} colors are currently shown.</span>}
</p>
{families.map((family) => {
const isOpen = openFamily === family.family
const hiddenInFam = family.colors.filter((c) => disabled.has(c.name)).length
return (
<div key={family.family} style={{ marginBottom: 6 }}>
{/* Family header — same style as customer view */}
<button
onClick={() => setOpenFamily(isOpen ? null : family.family)}
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: isOpen ? '#f0fafb' : '#fafafa',
border: '1px solid ' + (isOpen ? '#b2e0e4' : '#e8e8e8'),
borderRadius: isOpen ? '10px 10px 0 0' : '10px',
padding: '0.55rem 0.9rem',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.15s',
}}
type="button"
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
{/* Preview dots */}
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{family.colors.slice(0, 7).map((c) => (
<span
key={c.name}
style={{
width: 13,
height: 13,
borderRadius: '50%',
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
backgroundSize: c.image ? '220%' : undefined,
border: '1px solid rgba(0,0,0,0.12)',
flexShrink: 0,
display: 'inline-block',
opacity: disabled.has(c.name) ? 0.25 : 1,
}}
/>
))}
</span>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: '#15384c' }}>
{family.family}
</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{hiddenInFam > 0 && (
<span style={{
background: '#c07000', color: '#fff',
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
padding: '2px 8px',
}}>
{hiddenInFam} hidden
</span>
)}
<span style={{ fontSize: '0.75rem', color: '#888' }}>{isOpen ? '▲' : '▼'}</span>
</span>
</button>
{/* Expanded color grid — same balloon swatch layout as customer */}
{isOpen && (
<div style={{
border: '1px solid #b2e0e4',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '0.75rem 0.9rem',
background: '#f8fdfd',
}}>
<div className="swatch-container">
{family.colors.map((color) => {
const isDisabled = disabled.has(color.name)
const imageSrc = color.image ? `/color/${color.image}` : null
return (
<div
key={color.name}
className="swatch-wrapper"
onClick={() => toggle(color.name)}
title={isDisabled ? `Enable ${color.name}` : `Hide ${color.name}`}
style={{ opacity: isDisabled ? 0.3 : 1, cursor: 'pointer' }}
>
<div className="color-swatch">
{imageSrc ? (
<div
className="color-background finish-image"
style={{ backgroundImage: `url('${imageSrc}')` }}
/>
) : (
<div
className="color-background"
style={{ background: color.hex }}
/>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="color-shine" src="/color/images/shine.svg" alt="" aria-hidden="true" />
</div>
<span style={{
fontSize: '0.6rem',
textAlign: 'center',
color: isDisabled ? '#c07000' : '#334854',
fontWeight: isDisabled ? 700 : 400,
lineHeight: 1.2,
maxWidth: '100%',
wordBreak: 'break-word',
}}>
{isDisabled ? '✕ ' : ''}{color.name}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
})}
</section>
<footer className="modal-card-foot" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="button is-small"
type="button"
onClick={() => setDisabled(new Set())}
>
Enable all
</button>
<button
className="button is-small is-warning"
type="button"
onClick={() => setDisabled(new Set(families.flatMap((f) => f.colors.map((c) => c.name))))}
>
Disable all
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="button is-small" type="button" onClick={onClose}>Cancel</button>
<button
className="button is-small is-info"
type="button"
onClick={() => { onSave(Array.from(disabled)); onClose() }}
>
Apply
</button>
</div>
</footer>
</div>
</div>
)
}

View File

@ -51,10 +51,18 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
) )
useEffect(() => { useEffect(() => {
const disabled = new Set(product.disabledColors ?? [])
fetch(BASE + '/colors.json') fetch(BASE + '/colors.json')
.then((r) => r.json()) .then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data)) .then((data: ColorFamily[]) => {
}, []) if (!disabled.size) { setFamilies(data); return }
setFamilies(
data
.map((f) => ({ ...f, colors: f.colors.filter((c) => !disabled.has(c.name)) }))
.filter((f) => f.colors.length > 0)
)
})
}, [product.disabledColors])
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }

View File

@ -39,6 +39,7 @@ export interface CatalogItem {
colorMin: number // minimum colors required when showColors=true (default 1) colorMin: number // minimum colors required when showColors=true (default 1)
colorMax: number | null // maximum colors allowed (null = unlimited) colorMax: number | null // maximum colors allowed (null = unlimited)
chromeSurchargePerColor: number // extra cents per chrome color selected (0 = flat chrome variation instead) chromeSurchargePerColor: number // extra cents per chrome color selected (0 = flat chrome variation instead)
disabledColors?: string[] // color names hidden from the picker for this item
variations: CatalogVariation[] // all enabled variations; first is the default variations: CatalogVariation[] // all enabled variations; first is the default
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */ /** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
quantityUnit?: string quantityUnit?: string

View File

@ -19,6 +19,8 @@ export interface ItemOverride {
colorMax?: number colorMax?: number
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */ /** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
chromeSurchargePerColor?: number chromeSurchargePerColor?: number
/** Color names that are hidden from the customer picker for this item. */
disabledColors?: string[]
/** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */ /** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
quantityUnit?: string quantityUnit?: string
} }