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:
parent
1861e10d6d
commit
e95ec68931
@ -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 */}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
221
estore/src/components/AdminColorFilter.tsx
Normal file
221
estore/src/components/AdminColorFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user