Compare commits

...

10 Commits

Author SHA1 Message Date
7bc84cea75 fix: shop catalog always reflects latest data after admin changes
- Add force-dynamic to /api/catalog so Next.js never serves a
  stale cached route response to the shop
- Add invalidateCatalogCache() to catalog-cache lib to drop the
  30s in-process memory cache on demand
- Call invalidateCatalogCache() after every admin PATCH/DELETE on
  an item so override saves are reflected on the very next shop
  request (no 30s delay)

Refresh from Square already updated the shared disk + memory cache;
force-dynamic ensures the shop route handler actually runs each time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:12:41 -04:00
27093bcd54 fix: multi-category checkboxes in admin + requires-delivery toggle
- Category selector replaced with checkboxes — items can now be
  assigned to multiple categories directly in admin (not just Square).
  Each category shows a "Square" label if it came from the Square
  assignment. Saves as categoriesOverride[] (array of category names).
- categoriesOverride takes precedence over old categoryOverride in the
  catalog route; old overrides still work as fallback.
- Requires-delivery toggle and custom rate fields were already in the
  code but needed container rebuild to appear — no logic change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 09:44:00 -04:00
0ea1b98a1f feat: required delivery toggle with custom rates per item
Items can now be marked as "requires delivery" in admin — these items
cannot be picked up and must be delivered (and struck).

- Admin item editor: "Requires delivery" checkbox + custom base/per-mile
  rate fields that appear when the toggle is on
- ProductCard: "Delivery & setup required" note on the card
- CartDrawer: pickup toggle is hidden and replaced with an explanation
  when any cart item requires delivery; the quote call passes the
  item's custom rate override (highest base + highest per-mile wins
  when multiple requires-delivery items are in the cart)
- delivery-quote API: accepts optional rateOverride to apply per-item
  pricing on top of the inferred tier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 09:31:29 -04:00
107ef43a0e fix: hide category tab when it's already shown as an occasion tab
If a "Mothers Day" or "Graduation" occasion is active and its
squareCategorySlug matches a product category, suppress the duplicate
regular category tab so it doesn't appear twice in the bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:45:46 -04:00
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
84ab6bef2d feat: featured items — admin toggle, badge, sorted to top
- Add featured to ItemOverride so it can be set per-item in admin
- Catalog API applies the override and sorts featured items before
  non-featured (within each group, sortOrder still applies)
- ProductCard shows a teal Featured badge on the image when featured
  and not sold out
- Admin item editor has a  Featured checkbox beside Hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:21:33 -04:00
6705293e50 fix/feat: hex conflict, scroll-to-top, search all, admin error emails
- Fix Chrome Rose Gold hex (#B76E79 → #C17F87) so it no longer
  conflicts with Classic Rose Gold; image still used for display
- ScrollToTop hides when cart drawer is open and uses z-index 98
  (below the drawer); uses drawerOpen from CartContext
- Search now switches to All tab automatically so results span every
  item, not just the active category
- Add sendAdminErrorAlert() to notify.ts; checkout route emails
  admin@beachpartyballoons.com on unexpected server errors and on
  critical calendar-write failures; card decline errors are not
  forwarded (customers can self-resolve those)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:19:29 -04:00
01c908e919 fix: color picker selection keyed on name instead of hex
Classic Rose Gold and Chrome Rose Gold share the same hex (#B76E79),
so clicking one would deselect the other. Switched all selection
checks (toggle, remove, highlight) to use color.name which is unique.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:08:57 -04:00
6865d2d437 fix: lock body scroll when any modal or drawer is open
Add useLockBodyScroll hook (sets overflow:hidden on body, restores on
unmount) and apply it to ColorPicker, AdminColorFilter, WelcomeModal,
and GuidedTour. CartDrawer uses an inline effect keyed on drawerOpen
since it is always mounted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:12:07 -04:00
e95ec68931 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 <noreply@anthropic.com>
2026-04-16 09:00:32 -04:00
23 changed files with 676 additions and 119 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

@ -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 ────────────────────────────────────────────────────────────────────
@ -42,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])
@ -723,9 +728,12 @@ function ItemEditor({
}) {
const ov = item._override
const [hidden, setHidden] = useState(ov.hidden ?? false)
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
const [hidden, setHidden] = useState(ov.hidden ?? false)
const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
// Multi-category selection: stores category names (labels). Initialise from new override or fall back to Square assignment.
const [selectedCatNames, setSelectedCatNames] = useState<string[]>(
ov.categoriesOverride ?? item.categoryLabels ?? [item.categoryLabel]
)
const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
const [showColors, setShowColors] = useState<boolean | null>(
ov.showColors != null ? ov.showColors : null
@ -753,7 +761,16 @@ function ItemEditor({
const [chromeSurcharge, setChromeSurcharge] = useState<string>(
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : ''
)
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
const [showColorFilter, setShowColorFilter] = useState(false)
const [quantityUnit, setQuantityUnit] = useState<string>(ov.quantityUnit ?? '')
const [requiresDelivery, setRequiresDelivery] = useState(ov.requiresDelivery ?? false)
const [deliveryBase, setDeliveryBase] = useState<string>(
ov.deliveryBaseOverride != null ? String(ov.deliveryBaseOverride / 100) : ''
)
const [deliveryPerMile, setDeliveryPerMile] = useState<string>(
ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : ''
)
// Create category
const [newCatName, setNewCatName] = useState('')
@ -777,11 +794,12 @@ function ItemEditor({
setError('')
const patch: Partial<ItemOverride> = {
hidden,
featured,
hiddenVariationIds: hiddenVars,
hiddenModifierIds: hiddenMods,
}
if (catOverride) patch.categoryOverride = catOverride
if (catLabel) patch.categoryLabelOverride = catLabel
// Always save categoriesOverride (replaces old single-field overrides)
patch.categoriesOverride = selectedCatNames
if (sortOrder !== '') patch.sortOrder = Number(sortOrder)
if (showColors !== null) patch.showColors = showColors
if (descOverride) patch.descriptionOverride = descOverride
@ -789,8 +807,12 @@ 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
patch.requiresDelivery = requiresDelivery || undefined
patch.deliveryBaseOverride = deliveryBase !== '' ? Math.round(Number(deliveryBase) * 100) : null
patch.deliveryPerMileOverride = deliveryPerMile !== '' ? Math.round(Number(deliveryPerMile) * 100) : null
const res = await fetch(`${BASE}/api/admin/items/${item.id}`, {
method: 'PATCH',
@ -812,8 +834,8 @@ function ItemEditor({
const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'DELETE' })
if (res.ok) {
setHidden(false)
setCatOverride('')
setCatLabel('')
setFeatured(item.featured ?? false)
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
setSortOrder('')
setShowColors(null)
setHiddenMods([])
@ -822,6 +844,9 @@ function ItemEditor({
setColorMin('')
setColorMax('')
setChromeSurcharge('')
setRequiresDelivery(false)
setDeliveryBase('')
setDeliveryPerMile('')
onSaved(item.id, {})
}
}
@ -844,15 +869,13 @@ function ItemEditor({
if (!newCatName.trim()) return
setCreatingCat(true)
const cat = await onCreateCategory(newCatName.trim())
setCatOverride(cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''))
setCatLabel(cat.name)
// Auto-select the newly created category
if (cat.id) setSelectedCatNames((prev) => [...prev, cat.name])
setNewCatName('')
setShowNewCat(false)
setCreatingCat(false)
}
const catSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return (
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
<div className="columns is-multiline">
@ -860,8 +883,8 @@ function ItemEditor({
{/* Left column */}
<div className="column is-half">
{/* Hidden toggle */}
<div className="field">
{/* Visibility toggles */}
<div className="field" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
<label className="checkbox" style={{ fontWeight: 600 }}>
<input
type="checkbox"
@ -871,37 +894,95 @@ function ItemEditor({
/>
Hidden from storefront
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#11b3be' }}>
<input
type="checkbox"
checked={featured}
onChange={(e) => setFeatured(e.target.checked)}
style={{ marginRight: 6, accentColor: '#11b3be' }}
/>
Featured
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#c0392b' }}>
<input
type="checkbox"
checked={requiresDelivery}
onChange={(e) => setRequiresDelivery(e.target.checked)}
style={{ marginRight: 6, accentColor: '#c0392b' }}
/>
🚗 Requires delivery
</label>
</div>
{/* Category */}
<div className="field">
<label className="label is-small">Category</label>
<div className="control">
<div className="select is-small is-fullwidth">
<select
value={catOverride || item._rawCategory}
onChange={(e) => {
const selected = categories.find((c) => catSlug(c.name) === e.target.value)
setCatOverride(e.target.value)
setCatLabel(selected?.name ?? e.target.value)
}}
>
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option>
{categories
.filter((c) => catSlug(c.name) !== item._rawCategory)
.map((c) => (
<option key={c.id} value={catSlug(c.name)}>{c.name}</option>
))}
</select>
{requiresDelivery && (
<div className="field" style={{ background: '#fff8f8', border: '1px solid #f5c6c6', borderRadius: 6, padding: '0.6rem 0.8rem', marginBottom: '0.75rem' }}>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.4rem' }}>
Custom delivery rates for this item (leave blank to use global tier defaults)
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<label className="label is-small" style={{ marginBottom: 2 }}>Base charge ($)</label>
<input
className="input is-small"
type="number"
min="0"
step="0.01"
placeholder="e.g. 75.00"
value={deliveryBase}
onChange={(e) => setDeliveryBase(e.target.value)}
style={{ width: 110 }}
/>
</div>
<div>
<label className="label is-small" style={{ marginBottom: 2 }}>Per mile ($)</label>
<input
className="input is-small"
type="number"
min="0"
step="0.01"
placeholder="e.g. 4.00"
value={deliveryPerMile}
onChange={(e) => setDeliveryPerMile(e.target.value)}
style={{ width: 110 }}
/>
</div>
</div>
</div>
)}
{/* Category — multi-select checkboxes */}
<div className="field">
<label className="label is-small">Categories <span className="has-text-grey-light" style={{ fontWeight: 'normal' }}>(item appears in all checked tabs)</span></label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto', border: '1px solid #e8e8e8', borderRadius: 6, padding: '6px 8px' }}>
{categories.map((c) => (
<label key={c.id} className="checkbox" style={{ fontSize: '0.85rem' }}>
<input
type="checkbox"
checked={selectedCatNames.includes(c.name)}
onChange={(e) => {
setSelectedCatNames((prev) =>
e.target.checked ? [...prev, c.name] : prev.filter((n) => n !== c.name)
)
}}
style={{ marginRight: 6 }}
/>
{c.name}
{(item.categoryLabels ?? [item.categoryLabel]).includes(c.name) && (
<span className="has-text-grey-light" style={{ fontSize: '0.72rem', marginLeft: 6 }}>Square</span>
)}
</label>
))}
{categories.length === 0 && (
<p className="is-size-7 has-text-grey">No categories found refresh from Square.</p>
)}
</div>
<button
className="button is-ghost is-small"
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
onClick={() => setShowNewCat(!showNewCat)}
type="button"
>
+ Create new category
+ Create new category in Square
</button>
{showNewCat && (
<div className="field has-addons" style={{ marginTop: 6 }}>
@ -1177,9 +1258,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.`}
</p>
{/* Color availability */}
<div style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="button is-small is-light"
onClick={() => setShowColorFilter(true)}
>
🎨 Manage available colors
{disabledColors.length > 0 && (
<span style={{
marginLeft: 6, background: '#c07000', color: '#fff',
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
padding: '1px 7px',
}}>
{disabledColors.length} hidden
</span>
)}
</button>
</div>
</div>
)}
{showColorFilter && (
<AdminColorFilter
disabledColors={disabledColors}
onSave={setDisabledColors}
onClose={() => setShowColorFilter(false)}
/>
)}
{/* Quantity unit */}
<div className="field" style={{ marginTop: '1rem' }}>
<label className="label is-small">Quantity unit</label>

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { setOverride, clearOverride } from '@/lib/overrides'
import { invalidateCatalogCache } from '@/lib/catalog-cache'
import type { ItemOverride } from '@/lib/overrides'
export async function PATCH(
@ -9,6 +10,7 @@ export async function PATCH(
try {
const patch: Partial<ItemOverride> = await request.json()
setOverride(params.id, patch)
invalidateCatalogCache() // shop picks up override changes on next request
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/items/patch] error:', err)
@ -23,6 +25,7 @@ export async function DELETE(
) {
try {
clearOverride(params.id)
invalidateCatalogCache() // shop picks up reset on next request
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/items/delete] error:', err)

View File

@ -3,6 +3,8 @@ import { getCatalog } from '@/lib/catalog-cache'
import { readOverrides } from '@/lib/overrides'
import type { CatalogItem } from '@/data/mock-catalog'
export const dynamic = 'force-dynamic'
function applyOverrides(items: CatalogItem[]): CatalogItem[] {
const overrides = readOverrides()
@ -12,13 +14,39 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
if (!ov) return item
return {
...item,
category: ov.categoryOverride ?? item.category,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
featured: ov.featured ?? item.featured,
// categoriesOverride (array of names) takes precedence over the old single-field overrides
...(ov.categoriesOverride?.length
? (() => {
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const cats = ov.categoriesOverride!.map(toSlug)
return {
categories: cats,
categoryLabels: ov.categoriesOverride!,
category: cats[0],
categoryLabel: ov.categoriesOverride![0],
}
})()
: {
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,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
quantityUnit: ov.quantityUnit ?? item.quantityUnit,
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
quantityUnit: ov.quantityUnit ?? item.quantityUnit,
requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery,
deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride,
deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride,
description: ov.descriptionOverride ?? item.description,
variations: item.variations
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
@ -32,6 +60,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
})
.filter((item) => !(overrides[item.id]?.hidden))
.sort((a, b) => {
const featDiff = (b.featured ? 1 : 0) - (a.featured ? 1 : 0)
if (featDiff !== 0) return featDiff
const aOrder = overrides[a.id]?.sortOrder ?? 0
const bOrder = overrides[b.id]?.sortOrder ?? 0
return aOrder - bOrder

View File

@ -331,6 +331,16 @@ export async function POST(req: NextRequest) {
console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', {
orderId: order.id, paymentId: payment.id, error: calendarWriteError,
})
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Calendar write failed — order not booked',
message: `Calendar write failed for order ${order.id}. Pre-auth ${payment.id} is being voided. Customer: ${customerName} (${customerEmail}).`,
context: { orderId: order.id, paymentId: payment.id, error: String(calendarWriteError) },
})
} catch { /* best effort */ }
})()
try {
await cancelSquarePayment(payment.id!)
console.log('[checkout] Pre-auth voided successfully:', payment.id)
@ -447,6 +457,20 @@ export async function POST(req: NextRequest) {
const userMessage = CARD_MESSAGES[code]
?? 'Something went wrong with your payment. Please try again or contact us for help.'
// Email admin for unexpected server errors (not card declines the customer can self-resolve)
if (!CARD_MESSAGES[code]) {
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Checkout error',
message: err instanceof Error ? err.message : String(err),
context: { code: code || '(none)', customerEmail, customerName },
})
} catch { /* best effort */ }
})()
}
return NextResponse.json({ error: userMessage }, { status: 500 })
}
}

View File

@ -3,9 +3,10 @@ import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
import { readDeliveryRates } from '@/lib/delivery-rates'
export async function POST(request: Request) {
const { address, itemNames } = await request.json() as {
address: string
itemNames: string[]
const { address, itemNames, rateOverride } = await request.json() as {
address: string
itemNames: string[]
rateOverride?: { base: number; perMile: number }
}
if (!address?.trim()) {
@ -19,6 +20,16 @@ export async function POST(request: Request) {
const tier = inferTier(itemNames ?? [])
const rates = readDeliveryRates()
// Apply per-item rate override if provided (overrides just base and perMile for the inferred tier)
if (rateOverride) {
rates[tier] = {
...rates[tier],
base: rateOverride.base,
perMile: rateOverride.perMile,
}
}
const quote = await calcDelivery(coords.lat, coords.lng, tier, rates)
if (quote.miles > 40) {

View File

@ -0,0 +1,223 @@
'use client'
import { useState, useEffect } from 'react'
import { BASE } from '@/lib/basepath'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
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) {
useLockBodyScroll()
const [families, setFamilies] = useState<ColorFamily[]>([])
const [disabled, setDisabled] = useState<Set<string>>(() => new Set(disabledColors))
const [openFamily, setOpenFamily] = useState<string | null>(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 (
<div className="modal is-active" onClick={onClose}>
<div
className="modal-card"
style={{ maxWidth: 680, width: '95vw' }}
onClick={(e) => e.stopPropagation()}
>
<header className="modal-card-head" style={{ background: '#11b3be', gap: '0.75rem' }}>
<p className="modal-card-title has-text-white" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Available Colors
</p>
<button className="delete" aria-label="close" onClick={onClose} style={{ flexShrink: 0 }} />
</header>
<section className="modal-card-body" style={{ maxHeight: '70vh', overflowY: 'auto', padding: '1rem 1.25rem' }}>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.75rem' }}>
Click a color to hide it from customers ordering this item.{' '}
{disabledCount > 0
? <strong style={{ color: '#c07000' }}>{disabledCount} of {totalColors} hidden.</strong>
: <span>All {totalColors} colors are currently shown.</span>}
</p>
{families.map((family) => {
const isOpen = openFamily === family.family
const hiddenInFam = family.colors.filter((c) => disabled.has(c.name)).length
return (
<div key={family.family} style={{ marginBottom: 6 }}>
{/* Family header — same style as customer view */}
<button
onClick={() => setOpenFamily(isOpen ? null : family.family)}
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: isOpen ? '#f0fafb' : '#fafafa',
border: '1px solid ' + (isOpen ? '#b2e0e4' : '#e8e8e8'),
borderRadius: isOpen ? '10px 10px 0 0' : '10px',
padding: '0.55rem 0.9rem',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.15s',
}}
type="button"
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
{/* Preview dots */}
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{family.colors.slice(0, 7).map((c) => (
<span
key={c.name}
style={{
width: 13,
height: 13,
borderRadius: '50%',
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
backgroundSize: c.image ? '220%' : undefined,
border: '1px solid rgba(0,0,0,0.12)',
flexShrink: 0,
display: 'inline-block',
opacity: disabled.has(c.name) ? 0.25 : 1,
}}
/>
))}
</span>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: '#15384c' }}>
{family.family}
</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{hiddenInFam > 0 && (
<span style={{
background: '#c07000', color: '#fff',
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
padding: '2px 8px',
}}>
{hiddenInFam} hidden
</span>
)}
<span style={{ fontSize: '0.75rem', color: '#888' }}>{isOpen ? '▲' : '▼'}</span>
</span>
</button>
{/* Expanded color grid — same balloon swatch layout as customer */}
{isOpen && (
<div style={{
border: '1px solid #b2e0e4',
borderTop: 'none',
borderRadius: '0 0 10px 10px',
padding: '0.75rem 0.9rem',
background: '#f8fdfd',
}}>
<div className="swatch-container">
{family.colors.map((color) => {
const isDisabled = disabled.has(color.name)
const imageSrc = color.image ? `/color/${color.image}` : null
return (
<div
key={color.name}
className="swatch-wrapper"
onClick={() => toggle(color.name)}
title={isDisabled ? `Enable ${color.name}` : `Hide ${color.name}`}
style={{ opacity: isDisabled ? 0.3 : 1, cursor: 'pointer' }}
>
<div className="color-swatch">
{imageSrc ? (
<div
className="color-background finish-image"
style={{ backgroundImage: `url('${imageSrc}')` }}
/>
) : (
<div
className="color-background"
style={{ background: color.hex }}
/>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="color-shine" src="/color/images/shine.svg" alt="" aria-hidden="true" />
</div>
<span style={{
fontSize: '0.6rem',
textAlign: 'center',
color: isDisabled ? '#c07000' : '#334854',
fontWeight: isDisabled ? 700 : 400,
lineHeight: 1.2,
maxWidth: '100%',
wordBreak: 'break-word',
}}>
{isDisabled ? '✕ ' : ''}{color.name}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
})}
</section>
<footer className="modal-card-foot" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="button is-small"
type="button"
onClick={() => setDisabled(new Set())}
>
Enable all
</button>
<button
className="button is-small is-warning"
type="button"
onClick={() => setDisabled(new Set(families.flatMap((f) => f.colors.map((c) => c.name))))}
>
Disable all
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="button is-small" type="button" onClick={onClose}>Cancel</button>
<button
className="button is-small is-info"
type="button"
onClick={() => { onSave(Array.from(disabled)); onClose() }}
>
Apply
</button>
</div>
</footer>
</div>
</div>
)
}

View File

@ -53,6 +53,13 @@ const STEP_ORDER: Step[] = ['cart', 'delivery', 'info', 'payment']
export default function CartDrawer() {
const { entries, drawerOpen, closeDrawer, removeEntry, updateQuantity, clearCart, totalItems } = useCart()
useEffect(() => {
if (!drawerOpen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [drawerOpen])
const [editingEntry, setEditingEntry] = useState<CartEntry | null>(null)
const [step, setStep] = useState<Step>('cart')
@ -60,6 +67,26 @@ export default function CartDrawer() {
const [shortRef, setShortRef] = useState<string | null>(null)
const [fulfillmentType, setFulfillmentType] = useState<'delivery' | 'pickup'>('pickup')
// If any item requires delivery, force delivery mode and suppress pickup option
const cartRequiresDelivery = useMemo(
() => entries.some((e) => e.product.requiresDelivery),
[entries]
)
// Effective fulfillment type — pickup blocked when any item requires delivery
const effectiveFulfillment = cartRequiresDelivery ? 'delivery' : fulfillmentType
// Merged delivery rate override: highest base + highest perMile across requires-delivery items
const deliveryRateOverride = useMemo(() => {
const overrideItems = entries.filter(
(e) => e.product.requiresDelivery &&
(e.product.deliveryBaseOverride != null || e.product.deliveryPerMileOverride != null)
)
if (!overrideItems.length) return undefined
const base = Math.max(...overrideItems.map((e) => e.product.deliveryBaseOverride ?? 0))
const perMile = Math.max(...overrideItems.map((e) => e.product.deliveryPerMileOverride ?? 0))
return { base, perMile }
}, [entries])
// Delivery step — persisted
const [street, setStreet] = useStoredString('bpb_street', '')
const [city, setCity] = useStoredString('bpb_city', '')
@ -148,7 +175,7 @@ export default function CartDrawer() {
const CT_TAX_RATE = 0.0635
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
const deliveryTotal = fulfillmentType === 'delivery' ? (quote?.totalCents ?? 0) : 0
const deliveryTotal = effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : 0
const taxCents = Math.round(subtotal * CT_TAX_RATE)
const grandTotal = subtotal + deliveryTotal + taxCents
@ -171,13 +198,13 @@ export default function CartDrawer() {
}),
})),
selectedColors: entries.flatMap((e) => e.selectedColors),
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined,
deliveryAddress: fulfillmentType === 'delivery' ? (fullAddress || undefined) : undefined,
deliveryTier: fulfillmentType === 'delivery' ? quote?.tier : undefined,
deliveryNotes: fulfillmentType === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined,
deliveryCents: fulfillmentType === 'delivery' ? (quote?.totalCents ?? 0) : undefined,
pickupSlotISO: fulfillmentType === 'pickup' ? pickupSlot?.slotISO : undefined,
deliverySlotISO: effectiveFulfillment === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: effectiveFulfillment === 'delivery' ? deliverySlot?.driveMinutes : undefined,
deliveryAddress: effectiveFulfillment === 'delivery' ? (fullAddress || undefined) : undefined,
deliveryTier: effectiveFulfillment === 'delivery' ? quote?.tier : undefined,
deliveryNotes: effectiveFulfillment === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined,
deliveryCents: effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : undefined,
pickupSlotISO: effectiveFulfillment === 'pickup' ? pickupSlot?.slotISO : undefined,
customerFirstName: custFirst,
customerLastName: custLast,
customerEmail: custEmail,
@ -185,7 +212,7 @@ export default function CartDrawer() {
grandTotal,
idempotencyKey: checkoutKey || undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [entries, fulfillmentType, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice])
}), [entries, effectiveFulfillment, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice])
const handleSuccess = (id: string, ref: string) => {
setOrderId(id)
@ -217,7 +244,11 @@ export default function CartDrawer() {
const res = await fetch(BASE + '/api/delivery-quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: fullAddress, itemNames: entries.map((e) => e.product.name) }),
body: JSON.stringify({
address: fullAddress,
itemNames: entries.map((e) => e.product.name),
rateOverride: deliveryRateOverride,
}),
})
const data = await res.json()
if (!res.ok) { setQuoteErr(data.error ?? 'Could not calculate delivery.'); return }
@ -306,31 +337,37 @@ export default function CartDrawer() {
)}
{/* Fulfillment toggle */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
{(['delivery', 'pickup'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => { setFulfillmentType(type); setPickupSlot(null); setPickupDate('') }}
style={{
flex: 1, padding: '7px 4px', fontSize: '0.82rem',
borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit',
border: `1px solid ${fulfillmentType === type ? '#11b3be' : '#d0d0d0'}`,
background: fulfillmentType === type ? '#11b3be' : '#fff',
color: fulfillmentType === type ? '#fff' : '#555',
fontWeight: fulfillmentType === type ? 'bold' : 'normal',
}}
>
{type === 'delivery' ? '🚗 Delivery' : '🏪 Pick Up'}
</button>
))}
</div>
{cartRequiresDelivery ? (
<p style={{ fontSize: '0.8rem', color: '#555', marginBottom: '0.75rem', background: '#f5f5f5', padding: '7px 10px', borderRadius: 6 }}>
🚗 One or more items require delivery &amp; setup pickup is not available for this order.
</p>
) : (
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
{(['delivery', 'pickup'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => { setFulfillmentType(type); setPickupSlot(null); setPickupDate('') }}
style={{
flex: 1, padding: '7px 4px', fontSize: '0.82rem',
borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit',
border: `1px solid ${effectiveFulfillment === type ? '#11b3be' : '#d0d0d0'}`,
background: effectiveFulfillment === type ? '#11b3be' : '#fff',
color: effectiveFulfillment === type ? '#fff' : '#555',
fontWeight: effectiveFulfillment === type ? 'bold' : 'normal',
}}
>
{type === 'delivery' ? '🚗 Delivery' : '🏪 Pick Up'}
</button>
))}
</div>
)}
<button
className="button is-info is-fullwidth"
onClick={() => setStep('delivery')}
>
{fulfillmentType === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
</button>
</>
)
@ -484,14 +521,14 @@ export default function CartDrawer() {
const deliveryFooter = (
<>
{fulfillmentType === 'delivery' && (
{effectiveFulfillment === 'delivery' && (
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
Delivery fee is based on driving distance from our shop.
</p>
)}
<button
className="button is-info is-fullwidth"
disabled={fulfillmentType === 'delivery' ? (!quote || !deliverySlot) : !pickupSlot}
disabled={effectiveFulfillment === 'delivery' ? (!quote || !deliverySlot) : !pickupSlot}
onClick={() => setStep('info')}
>
Continue to Your Info
@ -576,7 +613,7 @@ export default function CartDrawer() {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Items</span><span>{fmt(subtotal)}</span>
</div>
{fulfillmentType === 'delivery' && quote && (
{effectiveFulfillment === 'delivery' && quote && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
</div>
@ -587,12 +624,12 @@ export default function CartDrawer() {
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
<span>Estimated total</span><span>{fmt(grandTotal)}</span>
</div>
{fulfillmentType === 'delivery' && deliverySlot && (
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{fulfillmentType === 'pickup' && pickupSlot && (
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
@ -649,7 +686,7 @@ export default function CartDrawer() {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Items</span><span>{fmt(subtotal)}</span>
</div>
{fulfillmentType === 'delivery' && quote && (
{effectiveFulfillment === 'delivery' && quote && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
</div>
@ -660,12 +697,12 @@ export default function CartDrawer() {
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
<span>Total</span><span>{fmt(grandTotal)}</span>
</div>
{fulfillmentType === 'delivery' && deliverySlot && (
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{fulfillmentType === 'pickup' && pickupSlot && (
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
@ -677,7 +714,7 @@ export default function CartDrawer() {
const bodyContent: Record<Step, React.ReactNode> = {
cart: cartBody,
delivery: fulfillmentType === 'pickup' ? pickupBody : deliveryBody,
delivery: effectiveFulfillment === 'pickup' ? pickupBody : deliveryBody,
info: infoBody,
payment: paymentSummary, // PaymentForm rendered separately below, always mounted
}
@ -749,7 +786,7 @@ export default function CartDrawer() {
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)}
{step === 'delivery' && fulfillmentType === 'pickup' ? 'Pickup Time' : STEP_TITLES[step]}
{step === 'delivery' && effectiveFulfillment === 'pickup' ? 'Pickup Time' : STEP_TITLES[step]}
{step === 'cart' && totalItems > 0 && ` (${totalItems})`}
</strong>
{/* Step indicator dots */}
@ -786,17 +823,17 @@ export default function CartDrawer() {
</p>
<p style={{ color: '#555', fontSize: '0.88rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Order <strong>#{shortRef}</strong> confirmed.{' '}
{fulfillmentType === 'pickup'
{effectiveFulfillment === 'pickup'
? <>Your pickup is all set see you at the shop! A confirmation will be sent to <strong>{custEmail}</strong>.</>
: <>We&rsquo;ll reach out to <strong>{custEmail}</strong> to confirm final delivery details.</>
}
</p>
{fulfillmentType === 'delivery' && deliverySlot && (
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{fulfillmentType === 'pickup' && pickupSlot && (
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>

View File

@ -6,6 +6,7 @@ import { useCart } from '@/context/CartContext'
import { BASE } from '@/lib/basepath'
import type { CartEntry } from '@/context/CartContext'
import { fmt } from '@/lib/format'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
interface ColorEntry {
name: string
@ -29,6 +30,7 @@ interface Props {
}
export default function ColorPicker({ product, maxColors, onClose, editingEntry }: Props) {
useLockBodyScroll()
const { addToCart, updateEntry } = useCart()
const [families, setFamilies] = useState<ColorFamily[]>([])
const [openFamily, setOpenFamily] = useState<string | null>(null)
@ -51,10 +53,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() }

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))
@ -78,11 +82,15 @@ export default function FeaturedProducts() {
return visible
}, [items, catOrder, catHidden])
const tabs = useMemo(() => [
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
{ key: 'all', label: 'All', occasion: false },
...productCategories.map((c) => ({ ...c, occasion: false })),
], [activeOccasions, productCategories])
const tabs = useMemo(() => {
// Category slugs already represented by an occasion tab — hide them from the regular tabs
const occasionSlugs = new Set(activeOccasions.map((o) => o.squareCategorySlug).filter(Boolean) as string[])
return [
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
{ key: 'all', label: 'All', occasion: false },
...productCategories.filter((c) => !occasionSlugs.has(c.key)).map((c) => ({ ...c, occasion: false })),
]
}, [activeOccasions, productCategories])
const activeOccasion: ActiveOccasion | undefined = useMemo(
() => activeOccasions.find((o) => o.key === category),
@ -122,11 +130,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)
)
@ -189,7 +197,7 @@ export default function FeaturedProducts() {
placeholder="Search…"
value={search}
autoFocus
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => { setSearch(e.target.value); if (e.target.value) setCategory('all') }}
onBlur={() => { if (!search) setSearchOpen(false) }}
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
style={{ width: '160px' }}

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
interface TourStep {
target: string | null // CSS selector, or null = centered modal
@ -62,6 +63,7 @@ interface Props {
}
export default function GuidedTour({ onDone, onStart }: Props) {
useLockBodyScroll()
const [step, setStep] = useState(0)
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)

View File

@ -55,6 +55,15 @@ export default function ProductCard({ item }: Props) {
) : (
<div className="no-image">🎈</div>
)}
{item.featured && !soldOut && (
<span style={{
position: 'absolute', top: 10, left: 10,
background: '#11b3be', color: '#fff',
fontWeight: 700, fontSize: '0.72rem', letterSpacing: '0.05em',
padding: '3px 10px', borderRadius: 4,
textTransform: 'uppercase',
}}>Featured</span>
)}
{soldOut && (
<div style={{
position: 'absolute', inset: 0,
@ -80,6 +89,11 @@ export default function ProductCard({ item }: Props) {
Only {stock} left
</p>
)}
{item.requiresDelivery && (
<p style={{ fontSize: '0.78rem', color: '#555', fontWeight: 600, marginBottom: '0.35rem' }}>
🚗 Delivery &amp; setup required
</p>
)}
<p className="is-size-7">{item.description}</p>
{item.tags.length > 0 && (

View File

@ -1,20 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
import { useCart } from '@/context/CartContext'
export default function ScrollToTop() {
const [visible, setVisible] = useState(false)
const { drawerOpen } = useCart()
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
setVisible(scrollTop > 130)
setScrolled(scrollTop > 130)
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (!visible) return null
if (!scrolled || drawerOpen) return null
return (
<button
@ -24,7 +26,7 @@ export default function ScrollToTop() {
position: 'fixed',
bottom: '12px',
right: '10px',
zIndex: 99,
zIndex: 98,
border: '1px solid #363636',
outline: 'none',
background: '#94d601',

View File

@ -1,5 +1,7 @@
'use client'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
interface Props {
onTour: () => void
onDismiss: () => void
@ -13,6 +15,7 @@ const HOW_IT_WORKS = [
]
export default function WelcomeModal({ onTour, onDismiss }: Props) {
useLockBodyScroll()
return (
<>
{/* Backdrop */}

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
@ -39,9 +42,16 @@ 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
/** When true, this item cannot be picked up — delivery is required. */
requiresDelivery?: boolean
/** Per-item delivery base charge override in cents. null = use tier default. */
deliveryBaseOverride?: number | null
/** Per-item per-mile rate override in cents. null = use tier default. */
deliveryPerMileOverride?: number | null
}
export const MOCK_CATALOG: CatalogItem[] = (([
@ -149,5 +159,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

@ -53,6 +53,12 @@ export async function refreshCatalog(): Promise<{ items: CatalogItem[]; fetchedA
return { items, fetchedAt: new Date() }
}
/** Drop the in-process memory cache so the next request re-reads from disk.
* Call this after writing overrides so the shop picks up changes immediately. */
export function invalidateCatalogCache(): void {
memCache = null
}
// ── Get (from cache or fetch) ─────────────────────────────────────────────────
export async function getCatalog(): Promise<{ items: CatalogItem[]; fetchedAt: Date | null }> {

View File

@ -368,3 +368,21 @@ export async function sendNewOrderAlert(params: {
text: lines.join('\n'),
})
}
export async function sendAdminErrorAlert(params: {
subject: string
message: string
context?: Record<string, unknown>
}): Promise<void> {
const to = 'admin@beachpartyballoons.com'
const lines = [
params.message,
'',
...(params.context
? Object.entries(params.context).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
: []),
'',
`Time: ${new Date().toISOString()}`,
]
await send({ to, subject: `⚠️ ${params.subject}`, text: lines.join('\n') })
}

View File

@ -4,6 +4,7 @@ import { atomicWriteJSON } from './file-utils'
export interface ItemOverride {
hidden?: boolean
featured?: boolean
categoryOverride?: string
categoryLabelOverride?: string
sortOrder?: number
@ -19,8 +20,18 @@ 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
/** Override the full list of display categories (stores category NAMES/labels). Replaces categoryOverride + categoryLabelOverride. */
categoriesOverride?: string[] | null
/** When true, pickup is not offered — item must be delivered. */
requiresDelivery?: boolean
/** Override delivery base charge in cents for this item (replaces the tier default). */
deliveryBaseOverride?: number | null
/** Override per-mile rate in cents for this item (replaces the tier default). */
deliveryPerMileOverride?: number | null
}
export type OverridesMap = Record<string, ItemOverride>

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,

View File

@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Locks body scroll while the calling component is mounted. */
export function useLockBodyScroll() {
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [])
}

View File

@ -181,7 +181,7 @@
"colors": [
{
"name": "Chrome Rose Gold",
"hex": "#B76E79",
"hex": "#C17F87",
"metallic": true,
"chromeType": "rosegold",
"image": "images/chrome-rosegold.webp"

View File

@ -130,7 +130,7 @@ function createColorSwatch(color) {
const swatch = document.createElement('div');
swatch.classList.add('color-swatch');
swatch.dataset.color = color.hex;
swatch.dataset.color = color.name;
swatch.style.color = color.hex;
swatch.setAttribute('role', 'button');
swatch.setAttribute('tabindex', '0');
@ -171,9 +171,9 @@ function createColorSwatch(color) {
colorName.textContent = color.name;
swatch.addEventListener('click', () => {
const isSelected = selectedPalette.some(c => c.hex === color.hex);
const isSelected = selectedPalette.some(c => c.name === color.name);
if (isSelected) {
selectedPalette = selectedPalette.filter(c => c.hex !== color.hex);
selectedPalette = selectedPalette.filter(c => c.name !== color.name);
} else {
selectedPalette.push(color);
}
@ -432,7 +432,7 @@ function renderSelectedPalette() {
swatchWrapper.appendChild(colorName);
swatch.addEventListener('click', () => {
selectedPalette = selectedPalette.filter(c => c.hex !== color.hex);
selectedPalette = selectedPalette.filter(c => c.name !== color.name);
renderSelectedPalette();
updateSwatchHighlights();
});
@ -460,7 +460,7 @@ function updateSwatchHighlights() {
const color = swatch.dataset.color;
const background = swatch.querySelector('.color-background');
const nameEl = swatch.parentElement.querySelector('.color-name');
const isSelected = selectedPalette.some(c => c.hex === color);
const isSelected = selectedPalette.some(c => c.name === color);
if (isSelected) {
background.classList.add('chosen');