From e95ec68931a6114ca0f27f85fdcd6b58e1acd063 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 16 Apr 2026 09:00:32 -0400 Subject: [PATCH] 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 --- estore/src/app/admin/page.tsx | 32 +++ estore/src/app/api/catalog/route.ts | 1 + estore/src/components/AdminColorFilter.tsx | 221 +++++++++++++++++++++ estore/src/components/ColorPicker.tsx | 12 +- estore/src/data/mock-catalog.ts | 1 + estore/src/lib/overrides.ts | 2 + 6 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 estore/src/components/AdminColorFilter.tsx diff --git a/estore/src/app/admin/page.tsx b/estore/src/app/admin/page.tsx index d94909d..690853d 100644 --- a/estore/src/app/admin/page.tsx +++ b/estore/src/app/admin/page.tsx @@ -7,6 +7,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog' import type { ItemOverride } from '@/lib/overrides' import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config' import type { HoursConfig, DayHours } from '@/lib/hours-config' +import AdminColorFilter from '@/components/AdminColorFilter' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -753,6 +754,8 @@ function ItemEditor({ const [chromeSurcharge, setChromeSurcharge] = useState( ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : '' ) + const [disabledColors, setDisabledColors] = useState(ov.disabledColors ?? []) + const [showColorFilter, setShowColorFilter] = useState(false) const [quantityUnit, setQuantityUnit] = useState(ov.quantityUnit ?? '') // Create category @@ -789,6 +792,7 @@ function ItemEditor({ if (colorMin !== '') patch.colorMin = Number(colorMin) if (colorMax !== '') patch.colorMax = Number(colorMax) if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100) + patch.disabledColors = disabledColors.length ? disabledColors : undefined if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() else patch.quantityUnit = undefined @@ -1177,9 +1181,37 @@ function ItemEditor({ 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.`}

+ + {/* Color availability */} +
+ +
)} + {showColorFilter && ( + setShowColorFilter(false)} + /> + )} + {/* Quantity unit */}
diff --git a/estore/src/app/api/catalog/route.ts b/estore/src/app/api/catalog/route.ts index 17c8bc3..de95b0b 100644 --- a/estore/src/app/api/catalog/route.ts +++ b/estore/src/app/api/catalog/route.ts @@ -18,6 +18,7 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { colorMin: ov.colorMin ?? item.colorMin, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, + disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors, quantityUnit: ov.quantityUnit ?? item.quantityUnit, description: ov.descriptionOverride ?? item.description, variations: item.variations diff --git a/estore/src/components/AdminColorFilter.tsx b/estore/src/components/AdminColorFilter.tsx new file mode 100644 index 0000000..45d9532 --- /dev/null +++ b/estore/src/components/AdminColorFilter.tsx @@ -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([]) + const [disabled, setDisabled] = useState>(() => new Set(disabledColors)) + const [openFamily, setOpenFamily] = useState(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 ( +
+
e.stopPropagation()} + > +
+

+ Available Colors +

+
+ +
+

+ Click a color to hide it from customers ordering this item.{' '} + {disabledCount > 0 + ? {disabledCount} of {totalColors} hidden. + : All {totalColors} colors are currently shown.} +

+ + {families.map((family) => { + const isOpen = openFamily === family.family + const hiddenInFam = family.colors.filter((c) => disabled.has(c.name)).length + + return ( +
+ {/* Family header — same style as customer view */} + + + {/* Expanded color grid — same balloon swatch layout as customer */} + {isOpen && ( +
+
+ {family.colors.map((color) => { + const isDisabled = disabled.has(color.name) + const imageSrc = color.image ? `/color/${color.image}` : null + + return ( +
toggle(color.name)} + title={isDisabled ? `Enable ${color.name}` : `Hide ${color.name}`} + style={{ opacity: isDisabled ? 0.3 : 1, cursor: 'pointer' }} + > +
+ {imageSrc ? ( +
+ ) : ( +
+ )} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + {isDisabled ? '✕ ' : ''}{color.name} + +
+ ) + })} +
+
+ )} +
+ ) + })} +
+ + +
+
+ ) +} diff --git a/estore/src/components/ColorPicker.tsx b/estore/src/components/ColorPicker.tsx index 6d6fdba..2684134 100644 --- a/estore/src/components/ColorPicker.tsx +++ b/estore/src/components/ColorPicker.tsx @@ -51,10 +51,18 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry ) useEffect(() => { + const disabled = new Set(product.disabledColors ?? []) fetch(BASE + '/colors.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(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } diff --git a/estore/src/data/mock-catalog.ts b/estore/src/data/mock-catalog.ts index 2f3f611..8774a82 100644 --- a/estore/src/data/mock-catalog.ts +++ b/estore/src/data/mock-catalog.ts @@ -39,6 +39,7 @@ export interface CatalogItem { colorMin: number // minimum colors required when showColors=true (default 1) colorMax: number | null // maximum colors allowed (null = unlimited) 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 /** Unit label for quantity, e.g. "ft". Omitted for plain count items. */ quantityUnit?: string diff --git a/estore/src/lib/overrides.ts b/estore/src/lib/overrides.ts index 715a44f..642d9b9 100644 --- a/estore/src/lib/overrides.ts +++ b/estore/src/lib/overrides.ts @@ -19,6 +19,8 @@ export interface ItemOverride { colorMax?: number /** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */ 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". */ quantityUnit?: string }