chris 623b237826 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>
2026-04-17 15:39:31 -04:00

373 lines
13 KiB
TypeScript

import { Client, Environment } from 'square'
import type { CatalogItem, CatalogVariation, ModifierList, ModifierOption } from '@/data/mock-catalog'
/** Payment client — uses SQUARE_ACCESS_TOKEN / SQUARE_ENVIRONMENT (may be sandbox during testing) */
function getClient() {
return new Client({
accessToken: process.env.SQUARE_ACCESS_TOKEN!,
environment:
process.env.SQUARE_ENVIRONMENT === 'production'
? Environment.Production
: Environment.Sandbox,
})
}
/**
* Catalog client — always uses production credentials so real items appear
* even when SQUARE_ENVIRONMENT is set to sandbox for payment testing.
* Falls back to the main client if no separate catalog token is configured.
*/
function getCatalogClient() {
const token = process.env.SQUARE_CATALOG_ACCESS_TOKEN ?? process.env.SQUARE_ACCESS_TOKEN!
const env = (process.env.SQUARE_CATALOG_ENVIRONMENT ?? process.env.SQUARE_ENVIRONMENT) === 'production'
? Environment.Production
: Environment.Sandbox
return new Client({ accessToken: token, environment: env })
}
export async function getSquareCatalog(): Promise<CatalogItem[]> {
const client = getCatalogClient()
// Fetch all pages (Square paginates listCatalog)
const allObjects: Awaited<ReturnType<typeof client.catalogApi.listCatalog>>['result']['objects'] = []
let cursor: string | undefined = undefined
do {
const response = await client.catalogApi.listCatalog(cursor, 'ITEM,IMAGE,CATEGORY,MODIFIER_LIST')
const page = response.result.objects ?? []
allObjects.push(...page)
cursor = response.result.cursor
} while (cursor)
const objects = allObjects
// Build modifier list lookup
const modifierMap = new Map<string, ModifierList>()
objects
.filter((o) => o.type === 'MODIFIER_LIST')
.forEach((ml) => {
const modifiers: ModifierOption[] = (ml.modifierListData?.modifiers ?? []).map((m) => ({
id: m.id!,
name: m.modifierData?.name ?? '',
priceDelta: m.modifierData?.priceMoney?.amount
? Number(m.modifierData.priceMoney.amount)
: null,
}))
modifierMap.set(ml.id!, {
id: ml.id!,
name: ml.modifierListData?.name ?? '',
selectionType: (ml.modifierListData?.selectionType ?? 'SINGLE') as 'SINGLE' | 'MULTIPLE',
minSelected: 0,
maxSelected: null,
options: modifiers,
})
})
// Build image URL lookup
const imageMap = new Map<string, string>()
objects
.filter((o) => o.type === 'IMAGE')
.forEach((img) => {
if (img.imageData?.url) imageMap.set(img.id!, img.imageData.url)
})
// Resolve category IDs by name from the catalog
const categoryId = (name: string) =>
objects.find(
(o) => o.type === 'CATEGORY' && o.categoryData?.name?.toLowerCase() === name
)?.id
const onlineCategoryId = categoryId('online')
const latexCategoryId = categoryId('latex')
// Build a full id → name map for all categories
const categoryNameMap = new Map<string, string>()
objects
.filter((o) => o.type === 'CATEGORY')
.forEach((c) => {
if (c.id && c.categoryData?.name) categoryNameMap.set(c.id, c.categoryData.name)
})
const items = objects
.filter((o) => o.type === 'ITEM')
.filter((o) =>
// 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!
const variations: CatalogVariation[] = (data.variations ?? []).map((v) => {
const varImageIds: string[] = v.itemVariationData?.imageIds ?? []
const varImageUrls = varImageIds
.map((id: string) => imageMap.get(id))
.filter((url: string | undefined): url is string => !!url)
return {
id: v.id!,
name: v.itemVariationData?.name ?? 'Default',
priceCents: v.itemVariationData?.priceMoney?.amount
? Number(v.itemVariationData.priceMoney.amount)
: 0,
imageUrls: varImageUrls,
inventory: null, // populated separately by getInventoryCounts()
}
})
const priceAmount =
data.variations?.[0]?.itemVariationData?.priceMoney?.amount
const imageUrls = (data.imageIds ?? [])
.map((id: string) => imageMap.get(id))
.filter((url: string | undefined): url is string => !!url)
const imageUrl = imageUrls[0] ?? null
const hasLatexColors = (data.modifierListInfo ?? []).some(
(info) => modifierMap.get(info.modifierListId!)?.name.toLowerCase().includes('latex colors')
)
const modifiers: ModifierList[] = (data.modifierListInfo ?? [])
.filter((info) => info.enabled !== false)
.filter((info) => !modifierMap.get(info.modifierListId!)?.name.toLowerCase().includes('latex colors'))
.map((info) => {
const ml = modifierMap.get(info.modifierListId!)
if (!ml) return null
return {
...ml,
minSelected: info.minSelectedModifiers ?? 0,
maxSelected: info.maxSelectedModifiers ?? null,
// Strip "None" options from every modifier list (e.g. weight)
options: ml.options.filter((o) => !o.name.toLowerCase().startsWith('none')),
}
})
.filter((ml): ml is ModifierList => ml !== null)
const itemCategories: { id?: string }[] = data.categories ?? []
// Derive display categories from all Square categories that aren't 'online' or 'latex'
const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[])
const displayCatNames = itemCategories
.filter((c) => c.id && !skipIds.has(c.id))
.map((c) => categoryNameMap.get(c.id!))
.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: primaryCatName,
categories: categorySlugs,
categoryLabels: categoryLbls,
price: priceAmount ? Number(priceAmount) : null,
imageUrl,
imageUrls,
featured: false,
tags: [],
modifiers,
showColors: hasLatexColors,
colorMin: 1,
colorMax: null,
chromeSurchargePerColor: 0,
variations,
}
})
return items
}
/**
* Fetch current inventory counts for a list of variation IDs.
* Returns a map of variationId → count (only tracked variations are included).
*/
export async function getInventoryCounts(
variationIds: string[]
): Promise<Map<string, number>> {
if (!variationIds.length) return new Map()
const client = getClient()
const locationId = process.env.SQUARE_LOCATION_ID!
const counts = new Map<string, number>()
// Square allows up to 1000 IDs per call; batch just in case
const BATCH = 1000
for (let i = 0; i < variationIds.length; i += BATCH) {
const batch = variationIds.slice(i, i + BATCH)
const { result } = await client.inventoryApi.batchRetrieveInventoryCounts({
catalogObjectIds: batch,
locationIds: [locationId],
})
for (const entry of result.counts ?? []) {
if (
entry.catalogObjectId &&
entry.state === 'IN_STOCK' &&
entry.quantity != null
) {
const qty = parseFloat(entry.quantity)
counts.set(entry.catalogObjectId, Math.max(0, Math.floor(qty)))
}
}
}
return counts
}
/** Find existing Square customer by email, or create one. Returns the customer ID. */
export async function upsertSquareCustomer(params: {
givenName: string
familyName: string
emailAddress: string
phoneNumber: string
}): Promise<string> {
const client = getClient()
// Search by email first
const { result: searchResult } = await client.customersApi.searchCustomers({
query: {
filter: {
emailAddress: { exact: params.emailAddress },
},
},
})
if (searchResult.customers?.length) {
return searchResult.customers[0].id!
}
// Create new customer
const { result: createResult } = await client.customersApi.createCustomer({
givenName: params.givenName,
familyName: params.familyName,
emailAddress: params.emailAddress,
phoneNumber: params.phoneNumber,
referenceId: 'shop',
})
return createResult.customer!.id!
}
export async function createSquareOrder(params: {
lineItems: Array<{
catalogObjectId?: string
name: string
quantity: string
basePriceMoney: { amount: bigint; currency: string }
}>
note: string
customerId?: string
idempotencyKey?: string
serviceCharge?: { name: string; amountCents: number; taxable: boolean }
fulfillment?: (
| { type: 'delivery'; recipientName: string; recipientPhone: string; addressLine1: string; deliverAt?: string; deliveryWindowDuration?: string; note?: string }
| { type: 'pickup'; recipientName: string; recipientPhone: string; pickupAt?: string }
)
}) {
const client = getClient()
const fulfillments: object[] = params.fulfillment
? params.fulfillment.type === 'delivery'
? [{
type: 'DELIVERY',
state: 'PROPOSED',
deliveryDetails: {
recipient: {
displayName: params.fulfillment.recipientName,
phoneNumber: params.fulfillment.recipientPhone,
address: { addressLine1: params.fulfillment.addressLine1 },
},
scheduleType: 'SCHEDULED',
...(params.fulfillment.deliverAt ? { deliverAt: params.fulfillment.deliverAt } : {}),
...(params.fulfillment.deliveryWindowDuration ? { deliveryWindowDuration: params.fulfillment.deliveryWindowDuration } : {}),
...(params.fulfillment.note ? { note: params.fulfillment.note } : {}),
},
}]
: [{
type: 'PICKUP',
state: 'PROPOSED',
pickupDetails: {
recipient: {
displayName: params.fulfillment.recipientName,
phoneNumber: params.fulfillment.recipientPhone,
},
pickupAt: params.fulfillment.pickupAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
}]
: []
const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production'
const serviceCharges = params.serviceCharge
? [{
name: params.serviceCharge.name,
amountMoney: { amount: BigInt(params.serviceCharge.amountCents), currency: 'USD' },
calculationPhase: 'SUBTOTAL_PHASE',
taxable: params.serviceCharge.taxable,
}]
: undefined
const { result } = await client.ordersApi.createOrder({
idempotencyKey: params.idempotencyKey,
order: {
locationId: process.env.SQUARE_LOCATION_ID!,
lineItems: params.lineItems.map((li) => ({
...li,
// Catalog object IDs only exist in production — strip them in sandbox
// so Square treats each line item as a custom (ad-hoc) entry instead.
catalogObjectId: isSandbox ? undefined : li.catalogObjectId,
})),
customerId: params.customerId,
serviceCharges: serviceCharges,
fulfillments: fulfillments.length ? fulfillments : undefined,
metadata: { source: 'online-shop' },
},
})
return result.order
}
export async function createSquarePayment(params: {
sourceId: string
orderId: string
amountMoney: { amount: bigint; currency: string }
note: string
idempotencyKey: string
autocomplete?: boolean // false = pre-authorize (hold) without capturing
}) {
const client = getClient()
const { result } = await client.paymentsApi.createPayment({
sourceId: params.sourceId,
idempotencyKey: params.idempotencyKey,
amountMoney: params.amountMoney,
orderId: params.orderId,
locationId: process.env.SQUARE_LOCATION_ID!,
note: params.note,
autocomplete: params.autocomplete ?? true,
})
return result.payment
}
/** Capture a pre-authorized payment (created with autocomplete: false). */
export async function completeSquarePayment(paymentId: string) {
const client = getClient()
const { result } = await client.paymentsApi.completePayment(paymentId, {})
return result.payment
}
/** Void a pre-authorized payment that was never captured. Customer is not charged. */
export async function cancelSquarePayment(paymentId: string) {
const client = getClient()
const { result } = await client.paymentsApi.cancelPayment(paymentId)
return result.payment
}
/** Retrieve a Square order with its full tender/payment details. */
export async function retrieveSquareOrder(orderId: string) {
const client = getClient()
const { result } = await client.ordersApi.retrieveOrder(orderId)
return result.order
}