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 catalogCats = useMemo(() => {
const seen = new Map<string, string>() const seen = new Map<string, string>()
items.forEach((item) => { 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 })) return Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
}, [items]) }, [items])

View File

@ -12,9 +12,15 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
if (!ov) return item if (!ov) return item
return { return {
...item, ...item,
featured: ov.featured ?? item.featured, featured: ov.featured ?? item.featured,
category: ov.categoryOverride ?? item.category, category: ov.categoryOverride ?? item.category,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, 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, showColors: ov.showColors != null ? ov.showColors : item.showColors,
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,

View File

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

View File

@ -28,6 +28,9 @@ export interface CatalogItem {
description: string description: string
category: string category: string
categoryLabel: 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 in cents of the default variation. null = custom quote required. */
price: number | null price: number | null
imageUrl: string | null imageUrl: string | null
@ -150,5 +153,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([
chromeSurchargePerColor: 0, chromeSurchargePerColor: 0,
imageUrls: item.imageUrl ? [item.imageUrl] : [], imageUrls: item.imageUrl ? [item.imageUrl] : [],
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [], variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
categories: [item.category],
categoryLabels: [item.categoryLabel],
...item, ...item,
})) as CatalogItem[] })) as CatalogItem[]

View File

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