feat: multi-category items and fix new items not appearing

Items can now belong to multiple Square categories and appear in all
matching tabs (e.g. a Mother's Day balloon also shows under Easter).

Also fixes new items not appearing when the Square account has no
"online" category — previously this caused zero items to load; now
it falls back to showing all items.

Changes:
- CatalogItem gains categories[] + categoryLabels[] (multi-category)
- square.ts collects all non-skip categories per item; "online" filter
  is now optional (show all if category doesn't exist in Square)
- catalog/route.ts propagates categoryOverride into categories[0]
- FeaturedProducts: tabs and filter use the full categories array
- Admin CategoryDisplayEditor sees all categories from multi-cat items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-17 15:39:31 -04:00
parent 84ab6bef2d
commit 623b237826
7 changed files with 64 additions and 21 deletions

View File

@ -0,0 +1,11 @@
{
"order": [
"latex",
"birthday",
"mylar-bouquets",
"graduation",
"letters-and-numbers",
"other"
],
"hidden": []
}

View File

@ -0,0 +1,8 @@
{
"mothers-day": {
"windowStart": "04-10"
},
"graduation": {
"windowStart": "04-01"
}
}

View File

@ -43,7 +43,11 @@ function CategoryDisplayEditor({ items }: { items: AdminItem[] }) {
const catalogCats = useMemo(() => {
const seen = new Map<string, string>()
items.forEach((item) => {
if (!seen.has(item.category)) seen.set(item.category, item.categoryLabel)
const cats = item.categories ?? [item.category]
const labels = item.categoryLabels ?? [item.categoryLabel]
cats.forEach((slug, i) => {
if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug)
})
})
return Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
}, [items])

View File

@ -12,9 +12,15 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
if (!ov) return item
return {
...item,
featured: ov.featured ?? item.featured,
category: ov.categoryOverride ?? item.category,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
featured: ov.featured ?? item.featured,
category: ov.categoryOverride ?? item.category,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
categories: ov.categoryOverride
? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)]
: (item.categories ?? [item.category]),
categoryLabels: ov.categoryLabelOverride
? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)]
: (item.categoryLabels ?? [item.categoryLabel]),
showColors: ov.showColors != null ? ov.showColors : item.showColors,
colorMin: ov.colorMin ?? item.colorMin,
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,

View File

@ -63,7 +63,11 @@ export default function FeaturedProducts() {
const productCategories = useMemo(() => {
const seen = new Map<string, string>()
items.forEach((item) => {
if (!seen.has(item.category)) seen.set(item.category, item.categoryLabel)
const cats = item.categories ?? [item.category]
const labels = item.categoryLabels ?? [item.categoryLabel]
cats.forEach((slug, i) => {
if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug)
})
})
const all = Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
const visible = all.filter((c) => !catHidden.includes(c.key))
@ -122,11 +126,11 @@ export default function FeaturedProducts() {
const filtered = (activeOccasion
? activeOccasion.squareCategorySlug
? items.filter((i) => i.category === activeOccasion.squareCategorySlug)
? items.filter((i) => (i.categories ?? [i.category]).includes(activeOccasion.squareCategorySlug!))
: items
: category === 'all'
? items
: items.filter((i) => i.category === category)
: items.filter((i) => (i.categories ?? [i.category]).includes(category))
).filter((i) =>
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
)

View File

@ -28,6 +28,9 @@ export interface CatalogItem {
description: string
category: string
categoryLabel: string
/** All display categories this item belongs to (multi-category support). First entry matches category/categoryLabel. */
categories: string[]
categoryLabels: string[]
/** Price in cents of the default variation. null = custom quote required. */
price: number | null
imageUrl: string | null
@ -150,5 +153,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([
chromeSurchargePerColor: 0,
imageUrls: item.imageUrl ? [item.imageUrl] : [],
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
categories: [item.category],
categoryLabels: [item.categoryLabel],
...item,
})) as CatalogItem[]

View File

@ -91,9 +91,10 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
const items = objects
.filter((o) => o.type === 'ITEM')
.filter((o) =>
onlineCategoryId
? (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
: false
// If an "online" category exists in Square, only show items tagged with it.
// If the category doesn't exist in this account, show all items.
!onlineCategoryId ||
(o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
)
.map((item) => {
const data = item.itemData!
@ -143,23 +144,27 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
.filter((ml): ml is ModifierList => ml !== null)
const itemCategories: { id?: string }[] = data.categories ?? []
const hasCategory = (id: string | undefined) =>
!!id && itemCategories.some((c) => c.id === id)
// Derive display category from the first Square category that isn't 'online' or 'latex'
// Derive display categories from all Square categories that aren't 'online' or 'latex'
const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[])
const displayCatName = itemCategories
const displayCatNames = itemCategories
.filter((c) => c.id && !skipIds.has(c.id))
.map((c) => categoryNameMap.get(c.id!))
.find(Boolean) ?? 'Other'
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
.filter((n): n is string => !!n)
const primaryCatName = displayCatNames[0] ?? 'Other'
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const categorySlug = toSlug(primaryCatName)
const categorySlugs = displayCatNames.length ? displayCatNames.map(toSlug) : [categorySlug]
const categoryLbls = displayCatNames.length ? displayCatNames : [primaryCatName]
return {
id: item.id!,
name: data.name ?? 'Unnamed item',
description: data.description ?? '',
category: categorySlug,
categoryLabel: displayCatName,
id: item.id!,
name: data.name ?? 'Unnamed item',
description: data.description ?? '',
category: categorySlug,
categoryLabel: primaryCatName,
categories: categorySlugs,
categoryLabels: categoryLbls,
price: priceAmount ? Number(priceAmount) : null,
imageUrl,
imageUrls,