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>
373 lines
13 KiB
TypeScript
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
|
|
}
|