Compare commits

...

5 Commits

Author SHA1 Message Date
dbd3589add Add Google Pay and Apple Pay support; fix catalog category filter
- PaymentForm now initialises Google Pay and Apple Pay via Square's Web
  Payments SDK alongside the existing card form; wallet buttons appear
  above the card with an "or pay with card" divider when available
- Apple Pay domain verification file added to public/.well-known/
- square.ts: fix online-category filter to show all items when the
  category doesn't exist; support multi-category display per item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:53:55 -04:00
e7fec9ea72 Merge beachPartyBalloons estore features into balloons-shop
- Multi-category support: CatalogItem gains categories/categoryLabels arrays;
  catalog route applies categoriesOverride; FeaturedProducts filters by array
- Featured sorting: featured items sort first in catalog route
- Admin panel: featured toggle, requiresDelivery with per-item rate overrides,
  multi-category checkboxes, variation visibility, AdminColorFilter modal,
  delivery rates tab (DeliveryRatesEditor)
- Per-item delivery rate overrides: delivery-quote route accepts rateOverride
  and reads from delivery-rates.json via readDeliveryRates()
- disabledColors, hiddenVariationIds applied in catalog and admin routes
- ScrollToTop button added to layout
- GuidedTour gains optional onStart prop; tourInit resets category/search
- Occasion tab deduplication in FeaturedProducts
- New components: ScrollToTop, AdminColorFilter, useLockBodyScroll,
  delivery-rates lib, admin/delivery-rates API route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:27:27 -04:00
b1606302b0 Restore osrm-init profile and fix host port mapping
Adds back the osrm-init service (profile: init) used by docker/osrm/update.sh
to extract, partition, and customize CT map data. Also documents that the host
port should be set to match the reverse proxy expectation (e.g. 8660:3000).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:02:33 -04:00
68bfe79db8 Expose vinylEnabled toggle in admin panel
item-overrides.json is gitignored (runtime data), so vinylEnabled must
be settable from the admin UI rather than committed directly. Adds the
"Enable vinyl configurator" checkbox alongside the existing vinyl promo
toggle so production overrides can be managed without touching files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:11:59 -04:00
1f1dabdb31 Add custom vinyl balloon configurator
Adds a per-letter vinyl text add-on tied to the Custom Vinyl Square item.
Customers pick a balloon shape (Heart/Star/Circle), type their message
(max 30 non-space chars, ASCII only — no emoji), and choose from 8 Google
Fonts rendered as live previews. Price updates in real time at $0.65/letter.

At checkout, vinyl orders expand to two Square line items: the 18" Shape
balloon at its catalog price and the Custom Vinyl service at the calculated
letter count price, with the font attached as a modifier.

Also adds a per-item admin toggle ("Suggest custom vinyl add-on") that shows
a promo note on any balloon's product modal pointing customers toward the
vinyl service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:03:38 -04:00
24 changed files with 1289 additions and 177 deletions

22
data/vinyl-config.json Normal file
View File

@ -0,0 +1,22 @@
{
"vinylItemId": "7RLHYXSCI3QBXXCSZHZHVEMG",
"vinylVariationId": "6BORBAGKFLW3A6BREWMV3CB6",
"pricePerLetterCents": 65,
"maxCharacters": 30,
"shapeItemId": "46JGZU6MYPMQL7M6USGL3KKB",
"shapes": [
{ "name": "Heart", "variationId": "OOR23NGE53SDQY6UQRE2X353", "priceCents": 450 },
{ "name": "Star", "variationId": "7GQY7OXJ6HGNPD2PS7EMP7SM", "priceCents": 450 },
{ "name": "Circle", "variationId": "EVI4L5I5ZT3RRONMXXZ3KQ3U", "priceCents": 450 }
],
"fonts": [
{ "id": "HY4DFCCRUHIV5HLUBUNAFGQY", "name": "Anton", "family": "Anton" },
{ "id": "KMUNDFS4G6QFWCOS5KOBCKHL", "name": "Montserrat", "family": "Montserrat" },
{ "id": "TH7LNXBNV2NKJG5DOSJDOF4K", "name": "Indie Flower", "family": "Indie Flower" },
{ "id": "56CZ2GXWZU3OBBXMGS4FECNX", "name": "Pacifico", "family": "Pacifico" },
{ "id": "JVYX3XCQI2FRB23MOS3PXBJX", "name": "Style Script", "family": "Style Script" },
{ "id": "5I7ICFBD2KUJFH7J3SHU4HX3", "name": "MedievalSharp", "family": "MedievalSharp" },
{ "id": "73RT5RUWTAP4OKLCZZJHV47J", "name": "Luckiest Guy", "family": "Luckiest Guy" },
{ "id": "ST3M6TDB6PRN2JMJXA6EBEZ4", "name": "Playfair Display","family": "Playfair Display" }
]
}

View File

@ -1,4 +1,16 @@
services:
osrm-init:
image: osrm/osrm-backend
container_name: osrm-init
profiles: [init]
volumes:
- ./docker/osrm/data:/data
entrypoint: /bin/sh -c
command: >
"osrm-extract -p /opt/car.lua /data/connecticut-latest.osm.pbf &&
osrm-partition /data/connecticut-latest.osrm &&
osrm-customize /data/connecticut-latest.osrm"
osrm:
image: osrm/osrm-backend
container_name: osrm

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { readDeliveryRates, writeDeliveryRates } from '@/lib/delivery-rates'
import type { DeliveryRatesConfig } from '@/lib/delivery'
export const dynamic = 'force-dynamic'
export async function GET() {
return NextResponse.json(readDeliveryRates())
}
export async function PUT(req: Request) {
try {
const body = (await req.json()) as DeliveryRatesConfig
const tiers = ['dropoff', 'classic', 'organic'] as const
for (const tier of tiers) {
const t = body[tier]
if (!t || typeof t.base !== 'number' || typeof t.perMile !== 'number' || typeof t.label !== 'string') {
return NextResponse.json({ error: `Invalid config for tier: ${tier}` }, { status: 400 })
}
}
writeDeliveryRates(body)
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/delivery-rates] error:', err)
return NextResponse.json({ error: 'Failed to save delivery rates' }, { status: 500 })
}
}

View File

@ -11,18 +11,47 @@ export async function GET() {
const withOverrides = items.map((item) => {
const ov = overrides[item.id] ?? {}
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
// Resolve categories (same logic as catalog route)
const resolvedCats = ov.categoriesOverride?.length
? (() => {
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]),
}
return {
...item,
// Resolved values (what the customer sees)
hidden: ov.hidden ?? false,
category: ov.categoryOverride ?? item.category,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
featured: ov.featured ?? item.featured ?? false,
...resolvedCats,
sortOrder: ov.sortOrder ?? 0,
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,
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
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)),
modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => {
@ -33,6 +62,7 @@ export async function GET() {
_rawCategory: item.category,
_rawCategoryLabel: item.categoryLabel,
_rawShowColors: item.showColors,
_rawVariations: item.variations,
_rawModifiers: item.modifiers,
_rawDescription: item.description,
_override: ov,

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,14 +14,44 @@ 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,
vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled,
vinylPromo: ov.vinylPromo ?? item.vinylPromo,
description: ov.descriptionOverride ?? item.description,
variations: item.variations
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => {
@ -30,6 +62,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

@ -1,10 +1,12 @@
import { NextResponse } from 'next/server'
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()) {
@ -17,7 +19,18 @@ export async function POST(request: Request) {
}
const tier = inferTier(itemNames ?? [])
const quote = await calcDelivery(coords.lat, coords.lng, tier)
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) {
return NextResponse.json(

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server'
import { readFileSync, existsSync } from 'fs'
import path from 'path'
export interface VinylShape {
name: string
variationId: string
priceCents: number
}
export interface VinylFont {
id: string
name: string
family: string
}
export interface VinylConfig {
vinylItemId: string
vinylVariationId: string
pricePerLetterCents: number
maxCharacters: number
shapeItemId: string
shapes: VinylShape[]
fonts: VinylFont[]
}
const CONFIG_PATH = path.join(process.cwd(), 'data', 'vinyl-config.json')
export async function GET() {
if (!existsSync(CONFIG_PATH)) {
return NextResponse.json({ error: 'Vinyl config not found' }, { status: 404 })
}
try {
const config: VinylConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
return NextResponse.json(config)
} catch {
return NextResponse.json({ error: 'Failed to load vinyl config' }, { status: 500 })
}
}

View File

@ -4,6 +4,7 @@ import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import CartDrawer from '@/components/CartDrawer'
import CartFab from '@/components/CartFab'
import ScrollToTop from '@/components/ScrollToTop'
import { CartProvider } from '@/context/CartContext'
export const metadata: Metadata = {
@ -49,6 +50,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<CartFab />
<main>{children}</main>
<Footer />
<ScrollToTop />
</CartProvider>
</body>
</html>

View File

@ -6,6 +6,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 ────────────────────────────────────────────────────────────────────
@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem {
_rawCategory: string
_rawCategoryLabel: string
_rawShowColors: boolean
_rawVariations: CatalogItem['variations']
_rawModifiers: ModifierList[]
_rawDescription: string
_override: ItemOverride
@ -579,6 +581,133 @@ function OccasionsEditor() {
)
}
// ─── Delivery Rates Editor ────────────────────────────────────────────────────
interface TierRate { base: number; perMile: number; label: string }
interface DeliveryRatesConfig { dropoff: TierRate; classic: TierRate; organic: TierRate }
const TIER_LABELS: Record<string, string> = {
dropoff: 'Drop-off',
classic: 'Setup & strike',
organic: 'Organic setup & strike',
}
function DeliveryRatesEditor() {
const [rates, setRates] = useState<DeliveryRatesConfig | null>(null)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/admin/delivery-rates')
.then((r) => r.json())
.then(setRates)
.catch(() => {})
}, [])
function updateTier(tier: keyof DeliveryRatesConfig, field: keyof TierRate, value: string) {
setRates((prev) => {
if (!prev) return prev
const updated = { ...prev[tier] }
if (field === 'label') {
updated.label = value
} else {
updated[field] = Math.round(parseFloat(value) * 100) || 0
}
return { ...prev, [tier]: updated }
})
}
async function handleSave() {
if (!rates) return
setSaving(true)
setMsg('')
const res = await fetch('/api/admin/delivery-rates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rates),
})
setSaving(false)
setMsg(res.ok ? 'Saved' : 'Save failed')
setTimeout(() => setMsg(''), 3000)
}
if (!rates) return <p className="has-text-grey">Loading</p>
return (
<div>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '1rem' }}>
Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.
</p>
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ minWidth: 200 }}>Tier</th>
<th style={{ width: 130 }}>Base fee ($)</th>
<th style={{ width: 130 }}>Per mile ($)</th>
<th style={{ minWidth: 240 }}>Label</th>
</tr>
</thead>
<tbody>
{(['dropoff', 'classic', 'organic'] as const).map((tier) => (
<tr key={tier}>
<td style={{ verticalAlign: 'middle', fontWeight: 500 }}>{TIER_LABELS[tier]}</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].base / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'base', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].perMile / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'perMile', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="text"
className="input is-small"
value={rates[tier].label}
onChange={(e) => updateTier(tier, 'label', e.target.value)}
style={{ width: '100%' }}
/>
</td>
</tr>
))}
</tbody>
</table>
<p className="help" style={{ marginBottom: '0.75rem' }}>
Formula: <strong>base + ceil(miles) × per-mile</strong>. Example: drop-off to a 5-mile address =
{' '}base + 5 × per-mile.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save rates
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
</div>
)
}
// ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({
@ -594,13 +723,19 @@ 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)
const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
// Multi-category selection: stores category names (labels).
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
)
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
const [saving, setSaving] = useState(false)
@ -623,13 +758,28 @@ 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('')
const [creatingCat, setCreatingCat] = useState(false)
const [showNewCat, setShowNewCat] = useState(false)
function toggleVar(id: string) {
setHiddenVars((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
function toggleMod(id: string) {
setHiddenMods((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
@ -641,10 +791,14 @@ function ItemEditor({
setError('')
const patch: Partial<ItemOverride> = {
hidden,
featured,
hiddenVariationIds: hiddenVars,
hiddenModifierIds: hiddenMods,
vinylEnabled: vinylEnabled || undefined,
vinylPromo: vinylPromo || undefined,
}
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
@ -652,8 +806,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(`/api/admin/items/${item.id}`, {
method: 'PATCH',
@ -675,16 +833,23 @@ function ItemEditor({
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
if (res.ok) {
setHidden(false)
setCatOverride('')
setCatLabel('')
setFeatured(item.featured ?? false)
setVinylEnabled(false)
setVinylPromo(false)
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
setSortOrder('')
setShowColors(null)
setHiddenVars([])
setHiddenMods([])
setDescOverride('')
setModifierMins({})
setColorMin('')
setColorMax('')
setChromeSurcharge('')
setDisabledColors([])
setRequiresDelivery(false)
setDeliveryBase('')
setDeliveryPerMile('')
onSaved(item.id, {})
}
}
@ -707,15 +872,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">
@ -723,8 +886,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"
@ -734,37 +897,121 @@ 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>
)}
{/* Vinyl options */}
<div className="field">
<label className="checkbox">
<input
type="checkbox"
checked={vinylEnabled}
onChange={(e) => setVinylEnabled(e.target.checked)}
style={{ marginRight: 6 }}
/>
Enable vinyl configurator
</label>
<p className="help">Shows the shape picker, text input, and font selector on this item (use for the Custom Vinyl product).</p>
</div>
<div className="field">
<label className="checkbox">
<input
type="checkbox"
checked={vinylPromo}
onChange={(e) => setVinylPromo(e.target.checked)}
style={{ marginRight: 6 }}
/>
Suggest custom vinyl add-on
</label>
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
</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 }}>
@ -858,6 +1105,42 @@ function ItemEditor({
{/* Right column */}
<div className="column is-half">
{/* Variations */}
{item._rawVariations.length > 1 && (
<div className="field">
<label className="label is-small">Variations</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{item._rawVariations.map((v) => {
const visible = !hiddenVars.includes(v.id)
return (
<div key={v.id} style={{
border: '1px solid #e8e8e8',
borderRadius: 6,
padding: '8px 10px',
background: visible ? '#fff' : '#fafafa',
opacity: visible ? 1 : 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="checkbox" style={{ fontWeight: 500, fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={visible}
onChange={() => toggleVar(v.id)}
style={{ marginRight: 6 }}
/>
{v.name}
</label>
<span className="has-text-grey-light" style={{ fontSize: '0.75rem' }}>
${(v.priceCents / 100).toFixed(2)}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Modifiers */}
{item._rawModifiers.length > 0 && (
<div className="field">
@ -1004,9 +1287,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>
@ -1204,7 +1515,7 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions'>('items')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
@ -1354,6 +1665,9 @@ export default function AdminPage() {
<li className={tab === 'occasions' ? 'is-active' : ''}>
<a onClick={() => setTab('occasions')}>Holidays</a>
</li>
<li className={tab === 'delivery' ? 'is-active' : ''}>
<a onClick={() => setTab('delivery')}>Delivery rates</a>
</li>
</ul>
</div>
@ -1456,6 +1770,9 @@ export default function AdminPage() {
{/* Holidays tab */}
{tab === 'occasions' && <OccasionsEditor />}
{/* Delivery rates tab */}
{tab === 'delivery' && <DeliveryRatesEditor />}
</div>
</section>
)

View File

@ -0,0 +1,154 @@
'use client'
import { useState, useEffect } from 'react'
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('/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 }}>
<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' }}>
<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-picker/${c.image}') center/cover` : c.hex,
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>
{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-picker/${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-picker/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

@ -119,6 +119,10 @@ export default function CartDrawer() {
// Unit price — uses selected variation price if set, otherwise product default
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => {
if (entry.vinylText !== undefined && entry.vinylText !== '') {
const letterCount = entry.vinylText.replace(/ /g, '').length
return (entry.vinylShapePriceCents ?? 0) + letterCount * 65
}
const base = entry.selectedVariationId
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
: (entry.product.price ?? 0)
@ -151,23 +155,53 @@ export default function CartDrawer() {
const grandTotal = subtotal + deliveryTotal + taxCents
// Build the payload sent to /api/checkout (recomputed only when dependencies change)
type LI = CheckoutPayload['lineItems'][number]
const checkoutPayload = useMemo<CheckoutPayload>(() => ({
lineItems: entries.map((e) => ({
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
catalogItemId: e.selectedVariationId ?? e.product.id,
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
const ml = e.product.modifiers.find((m) => m.id === listId)
if (!ml) return []
return optIds.map((optId) => ({
catalogObjectId: optId,
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
}))
}),
})),
lineItems: entries.flatMap((e): LI[] => {
if (e.vinylText && e.vinylShapeVariationId) {
const letterCount = e.vinylText.replace(/ /g, '').length
const vinylCents = letterCount * 65
return [
{
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
quantity: e.quantity,
priceCents: e.vinylShapePriceCents ?? 450,
catalogItemId: e.vinylShapeVariationId,
note: 'For custom vinyl',
},
{
name: 'Custom Vinyl',
quantity: e.quantity,
priceCents: vinylCents,
catalogItemId: e.product.variations[0]?.id ?? e.product.id,
note: [
`Text: "${e.vinylText}"`,
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
e.notes || null,
].filter(Boolean).join(' | ') || undefined,
modifiers: e.vinylFontId
? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }]
: undefined,
},
]
}
return [{
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
catalogItemId: e.selectedVariationId ?? e.product.id,
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
const ml = e.product.modifiers.find((m) => m.id === listId)
if (!ml) return []
return optIds.map((optId) => ({
catalogObjectId: optId,
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
}))
}),
}]
}),
selectedColors: entries.flatMap((e) => e.selectedColors),
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined,
@ -267,26 +301,37 @@ export default function CartDrawer() {
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
)}
</div>
{entry.selectedColors.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
Colors: {entry.selectedColors.join(', ')}
</div>
)}
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
if (!optIds.length) return null
const ml = entry.product.modifiers?.find((m) => m.id === listId)
if (!ml) return null
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id)
return (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
{entry.vinylText ? (
<div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
<div>Shape: 18&quot; {entry.vinylShapeName}</div>
<div>Text: &ldquo;{entry.vinylText}&rdquo;</div>
{entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
{entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>&ldquo;{entry.notes}&rdquo;</div>}
</div>
) : (
<>
{entry.selectedColors.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
Colors: {entry.selectedColors.join(', ')}
</div>
)}
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
if (!optIds.length) return null
const ml = entry.product.modifiers?.find((m) => m.id === listId)
if (!ml) return null
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id)
return (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
</div>
)}
</>
)}
</div>
))

View File

@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import { useCart } from '@/context/CartContext'
import type { CartEntry } from '@/context/CartContext'
import { fmt } from '@/lib/format'
import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route'
interface ColorEntry {
name: string
@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
() => editingEntry?.selectedVariationId ?? product.variations[0]?.id
)
// Vinyl state
const [vinylConfig, setVinylConfig] = useState<VinylConfig | null>(null)
const [vinylText, setVinylText] = useState(editingEntry?.vinylText ?? '')
const [vinylFontId, setVinylFontId] = useState(editingEntry?.vinylFontId ?? '')
const [vinylShape, setVinylShape] = useState<VinylShape | null>(
() => null // resolved after config loads
)
useEffect(() => {
fetch('/colors.json')
.then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data))
}, [])
useEffect(() => {
if (!product.vinylEnabled) return
fetch('/api/vinyl-config')
.then((r) => r.json())
.then((cfg: VinylConfig) => {
setVinylConfig(cfg)
// Restore shape selection when editing
const savedId = editingEntry?.vinylShapeVariationId
if (savedId) {
setVinylShape(cfg.shapes.find((s) => s.variationId === savedId) ?? cfg.shapes[0])
} else {
setVinylShape(cfg.shapes[0])
}
// Load Google Fonts for previews
const families = cfg.fonts.map((f) => `family=${encodeURIComponent(f.family).replace(/%20/g, '+')}`).join('&')
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`
if (!document.querySelector(`link[href="${href}"]`)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
document.head.appendChild(link)
}
})
.catch(() => {/* vinyl config unavailable — fail silently */})
}, [product.vinylEnabled, editingEntry?.vinylShapeVariationId])
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
return ml.minSelected > 0 && chosen < ml.minSelected
})
const needsColors = product.showColors && selected.size < colorMin
const canAdd = missingModifiers.length === 0 && !needsColors
// Vinyl validation
const vinylLetterCount = vinylText.replace(/ /g, '').length
const vinylMaxChars = vinylConfig?.maxCharacters ?? 30
const vinylPriceCents = product.vinylEnabled
? (vinylShape?.priceCents ?? 0) + vinylLetterCount * (vinylConfig?.pricePerLetterCents ?? 65)
: 0
const needsVinylText = product.vinylEnabled && vinylLetterCount === 0
const needsVinylFont = product.vinylEnabled && !vinylFontId
const canAdd = missingModifiers.length === 0 && !needsColors && !needsVinylText && !needsVinylFont
// Selectable variations: everything except the chrome variation (auto-applied by color choice)
const selectableVariations = product.variations.filter(
@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
}, 0)
const basePrice = activeVariation?.priceCents ?? product.price ?? 0
const chromeDelta = chromeCount * surchargePerColor
const unitPrice = basePrice + modDelta + chromeDelta
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
const unitPrice = product.vinylEnabled
? vinylPriceCents
: basePrice + modDelta + chromeDelta
const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote'
return (
<div className="modal is-active" onClick={onClose}>
@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</p>
)}
{/* ── Vinyl promo note ── */}
{product.vinylPromo && (
<div style={{ marginBottom: '1.25rem', padding: '0.65rem 0.9rem', background: '#f3eeff', border: '1px solid #d8c8f8', borderRadius: '8px', fontSize: '0.82rem', color: '#4a2d9e' }}>
Want to personalize this balloon? Add a <strong>Custom Vinyl</strong> item to your order and we&apos;ll apply custom text in your choice of font starting at $4.50 + $0.65/letter.
</div>
)}
{/* ── Size / variation selector ── */}
{selectableVariations.length > 1 && (
<div className="field" style={{ marginBottom: '1.25rem' }}>
@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</div>
))}
{/* ── Vinyl configurator ── */}
{product.vinylEnabled && vinylConfig && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f8f4ff', border: '1px solid #d8c8f8', borderRadius: '10px' }}>
<p className="label" style={{ marginBottom: '0.75rem', color: '#5a3e9e' }}>
Custom Vinyl Text
</p>
{/* Shape picker */}
<div className="field" style={{ marginBottom: '1rem' }}>
<label className="label is-small">Balloon Shape</label>
<div className="buttons">
{vinylConfig.shapes.map((shape) => (
<button
key={shape.variationId}
type="button"
className={`button is-small${vinylShape?.variationId === shape.variationId ? ' is-info' : ''}`}
onClick={() => setVinylShape(shape)}
>
{shape.name}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: '0.78em' }}>{fmt(shape.priceCents)}</span>
</button>
))}
</div>
</div>
{/* Text input */}
<div className="field" style={{ marginBottom: '1rem' }}>
<label className="label is-small">
Your message
<span style={{ fontWeight: 'normal', marginLeft: '8px', color: vinylLetterCount > vinylMaxChars ? '#e00' : '#888' }}>
{vinylLetterCount}/{vinylMaxChars} letters (spaces free)
</span>
</label>
<div className="control">
<input
className="input"
type="text"
placeholder="e.g. Happy Birthday!"
value={vinylText}
onChange={(e) => {
// Strip anything outside standard ASCII printable range (no emojis, accented chars, etc.)
const sanitized = e.target.value.replace(/[^\x20-\x7E]/g, '')
const nonSpace = sanitized.replace(/ /g, '').length
if (nonSpace <= vinylMaxChars) setVinylText(sanitized)
}}
/>
</div>
{vinylLetterCount > 0 && (
<p className="is-size-7" style={{ marginTop: '0.25rem', color: '#5a3e9e' }}>
{vinylLetterCount} letter{vinylLetterCount !== 1 ? 's' : ''} × {fmt(vinylConfig.pricePerLetterCents)} = {fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)} vinyl
</p>
)}
</div>
{/* Font picker */}
<div className="field" style={{ marginBottom: '0.5rem' }}>
<label className="label is-small">Font Style</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '8px' }}>
{vinylConfig.fonts.map((font) => {
const chosen = vinylFontId === font.id
return (
<button
key={font.id}
type="button"
onClick={() => setVinylFontId(font.id)}
style={{
padding: '10px 8px',
border: `2px solid ${chosen ? '#7c4dff' : '#d8c8f8'}`,
borderRadius: '8px',
background: chosen ? '#ede7ff' : '#fff',
cursor: 'pointer',
textAlign: 'center',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span style={{ fontFamily: `'${font.family}', sans-serif`, fontSize: '1.3rem', display: 'block', color: '#2d1b6b', lineHeight: 1.2 }}>
{vinylText || 'Aa'}
</span>
<span style={{ fontSize: '0.68rem', color: '#888', marginTop: '4px', display: 'block' }}>
{font.name}
</span>
</button>
)
})}
</div>
</div>
{/* Live price summary */}
{vinylLetterCount > 0 && vinylShape && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem 0.85rem', background: '#ede7ff', borderRadius: '8px', fontSize: '0.82rem', color: '#3d2080' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>18&quot; {vinylShape.name} balloon</span>
<span>{fmt(vinylShape.priceCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Vinyl ({vinylLetterCount} letters)</span>
<span>{fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, borderTop: '1px solid #c4b0f0', marginTop: '4px', paddingTop: '4px' }}>
<span>Total</span>
<span>{fmt(vinylPriceCents)}</span>
</div>
</div>
)}
</div>
)}
{/* ── Notes ── */}
<div className="field mt-4">
<label className="label">Special notes (optional)</label>
@ -526,10 +687,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && (
<p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}>
Please select:{' '}
Please:{' '}
{[
needsColors ? `at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
...missingModifiers.map((ml) => ml.name),
needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
needsVinylText ? 'enter your vinyl message' : null,
needsVinylFont ? 'choose a font style' : null,
...missingModifiers.map((ml) => `choose ${ml.name}`),
].filter(Boolean).join(', ')}
</p>
)}
@ -544,12 +707,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
if (v.size) choices[k] = Array.from(v)
})
const selectedVariationId = activeVariation?.id
// Store the user's manual variation choice (not the chrome override) so editing restores the right selection
const storedVariationId = userVariation?.id ?? selectedVariationId
const selectedFont = vinylConfig?.fonts.find((f) => f.id === vinylFontId)
const vinylFields = product.vinylEnabled && vinylText && vinylShape ? {
vinylText,
vinylFontId,
vinylFontName: selectedFont?.name,
vinylShapeVariationId: vinylShape.variationId,
vinylShapeName: vinylShape.name,
vinylShapePriceCents: vinylShape.priceCents,
} : {}
if (editingEntry) {
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
} else {
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
}
onClose()
}}

View File

@ -47,6 +47,12 @@ export default function FeaturedProducts() {
setShowTour(true)
}
const tourInit = () => {
setCategory('all')
setSearch('')
setSearchOpen(false)
}
const endTour = () => {
setShowTour(false)
// Close any customization modal that may have been opened during the tour
@ -56,7 +62,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))
@ -71,11 +81,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),
@ -115,11 +129,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)
)
@ -182,7 +196,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' }}
@ -223,7 +237,7 @@ export default function FeaturedProducts() {
{/* Welcome modal + guided tour */}
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
{showTour && <GuidedTour onDone={endTour} />}
{showTour && <GuidedTour onDone={endTour} onStart={tourInit} />}
{/* Product grid */}
{loading ? (

View File

@ -57,10 +57,11 @@ const PAD = 10 // px padding around spotlight
const TIP_WIDTH = 300 // tooltip width in px
interface Props {
onDone: () => void
onDone: () => void
onStart?: () => void
}
export default function GuidedTour({ onDone }: Props) {
export default function GuidedTour({ onDone, onStart }: Props) {
const [step, setStep] = useState(0)
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
@ -72,6 +73,11 @@ export default function GuidedTour({ onDone }: Props) {
if (el) setTargetRect(el.getBoundingClientRect())
}, [current.target])
useEffect(() => {
onStart?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// On step change: fire onEnter, poll until target appears, then scroll + measure.
useEffect(() => {
setTargetRect(null) // clear stale rect immediately

View File

@ -1,9 +1,9 @@
'use client'
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
import { useEffect, useRef, useState } from 'react'
import { fmt } from '@/lib/format'
// ── Minimal Square Web Payments SDK types ─────────────────────────────────────
// ── Square Web Payments SDK types ─────────────────────────────────────────────
declare global {
interface Window {
Square?: {
@ -11,18 +11,33 @@ declare global {
}
}
}
interface PaymentRequestOptions {
countryCode: string
currencyCode: string
total: { label: string; amount: string }
}
interface SquarePaymentRequest {} // opaque handle passed to wallet methods
interface SquarePayments {
card(options?: object): Promise<SquareCard>
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
}
interface SquareCard {
attach(selector: string): Promise<void>
tokenize(): Promise<{
status: string
token?: string
errors?: Array<{ message: string }>
}>
tokenize(): Promise<TokenResult>
destroy(): Promise<void>
}
interface SquareWalletMethod {
attach(selector: string): Promise<void>
tokenize(): Promise<TokenResult>
destroy(): Promise<void>
}
interface TokenResult {
status: string
token?: string
errors?: Array<{ message: string }>
}
// ─────────────────────────────────────────────────────────────────────────────
export interface CheckoutPayload {
@ -66,11 +81,16 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
const [sdkReady, setSdkReady] = useState(false)
const [cardReady, setCardReady] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const cardRef = useRef<SquareCard | null>(null)
const [sdkReady, setSdkReady] = useState(false)
const [cardReady, setCardReady] = useState(false)
const [googlePayReady, setGooglePayReady] = useState(false)
const [applePayReady, setApplePayReady] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const cardRef = useRef<SquareCard | null>(null)
const googlePayRef = useRef<SquareWalletMethod | null>(null)
const applePayRef = useRef<SquareWalletMethod | null>(null)
// 1 — Load Square SDK script (idempotent)
useEffect(() => {
@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
// Clear any previous error when the booking slot changes
useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO])
// 2 — Initialise card form once SDK is ready and the step is visible
// 2 — Initialise payment methods once SDK is ready and the step is visible
useEffect(() => {
if (!active || !sdkReady || !window.Square || !appId || !locationId) return
if (cardRef.current) return // already initialised
@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
;(async () => {
try {
// Double-rAF: wait for the browser to finish painting so #sq-card is in the DOM
// Double-rAF: wait for the browser to finish painting so all containers are in the DOM
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
@ -109,14 +129,39 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
}
const payments = await window.Square!.payments(appId, locationId)
const card = await payments.card()
// Card (always available)
const card = await payments.card()
await card.attach('#sq-card')
if (mounted) {
cardRef.current = card
setCardReady(true)
} else {
card.destroy().catch(() => {})
}
if (mounted) { cardRef.current = card; setCardReady(true) }
else { card.destroy().catch(() => {}); return }
// Google Pay (available in Chrome and most modern browsers)
try {
const req = payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) },
})
const gp = await payments.googlePay(req)
await gp.attach('#sq-google-pay')
if (mounted) { googlePayRef.current = gp; setGooglePayReady(true) }
else gp.destroy().catch(() => {})
} catch { /* not available in this browser or environment */ }
// Apple Pay (Safari on Apple devices only, requires registered domain)
try {
const req = payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) },
})
const ap = await payments.applePay(req)
await ap.attach('#sq-apple-pay')
if (mounted) { applePayRef.current = ap; setApplePayReady(true) }
else ap.destroy().catch(() => {})
} catch { /* not available (non-Safari / domain not yet registered with Apple) */ }
} catch (e) {
console.error('[PaymentForm] init:', e)
if (mounted) setError('Could not load payment form — please refresh and try again.')
@ -128,17 +173,55 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
cardRef.current?.destroy().catch(() => {})
cardRef.current = null
setCardReady(false)
googlePayRef.current?.destroy().catch(() => {})
googlePayRef.current = null
setGooglePayReady(false)
applePayRef.current?.destroy().catch(() => {})
applePayRef.current = null
setApplePayReady(false)
}
}, [active, sdkReady, appId, locationId])
const handlePay = async () => {
// Shared: POST token to checkout API with one auto-retry on network failure
const submitToken = async (token: string) => {
const checkoutBody = JSON.stringify({ ...payload, sourceId: token })
const attemptCheckout = () => fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: checkoutBody,
})
let res: Response
try {
res = await attemptCheckout()
} catch {
await new Promise((r) => setTimeout(r, 2000))
try {
res = await attemptCheckout()
} catch {
setError(
'Connection issue — your payment may have already been processed. ' +
'Please tap "Place Order" again to confirm, or contact us if this persists.'
)
return
}
}
const data = await res.json()
if (!res.ok || !data.success) {
setError(data.error ?? 'Checkout failed — please try again or contact us.')
if (res.status !== 409) console.error('[checkout] response:', data)
return
}
onSuccess(data.orderId as string, data.shortRef as string)
}
const handleCardPay = async () => {
if (!cardRef.current || submitting) return
setSubmitting(true)
setError('')
try {
const tokenResult = await cardRef.current.tokenize()
if (tokenResult.status !== 'OK' || !tokenResult.token) {
setError(
tokenResult.errors?.map((e) => e.message).join(' ') ??
@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
)
return
}
await submitToken(tokenResult.token)
} finally {
setSubmitting(false)
}
}
const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token })
// Attempt the checkout request. On a network-level failure (fetch throws),
// wait 2 seconds and retry once automatically — the idempotency key ensures
// no double charge and will return success if the first attempt already
// captured payment but the response was lost.
const attemptCheckout = () => fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: checkoutBody,
})
let res: Response
try {
res = await attemptCheckout()
} catch {
// Network failure on first attempt — pause and retry once
await new Promise((r) => setTimeout(r, 2000))
try {
res = await attemptCheckout()
} catch {
// Both attempts failed — payment may or may not have gone through.
// The idempotency key is preserved in localStorage, so clicking
// "Place Order" again will safely resolve either way.
const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => {
if (!walletRef.current || submitting) return
setSubmitting(true)
setError('')
try {
const tokenResult = await walletRef.current.tokenize()
if (tokenResult.status !== 'OK' || !tokenResult.token) {
// 'Cancel' means the user dismissed the native sheet — not an error
if (tokenResult.status !== 'Cancel') {
setError(
'Connection issue — your payment may have already been processed. ' +
'Please tap "Place Order" again to confirm, or contact us if this persists.'
tokenResult.errors?.map((e) => e.message).join(' ') ??
'Payment was not completed. Please try again.'
)
return
}
}
const data = await res.json()
if (!res.ok || !data.success) {
setError(data.error ?? 'Checkout failed — please try again or contact us.')
if (res.status !== 409) console.error('[checkout] response:', data)
return
}
onSuccess(data.orderId as string, data.shortRef as string)
await submitToken(tokenResult.token)
} finally {
setSubmitting(false)
}
@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
)
}
const hasWallet = googlePayReady || applePayReady
return (
<div>
{/* Wallet button containers always in the DOM so the SDK can attach to them;
hidden via display:none until the respective method initialises */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: hasWallet ? '1rem' : '0' }}>
<div
id="sq-google-pay"
onClick={() => handleWalletPay(googlePayRef)}
style={{ display: googlePayReady ? 'block' : 'none', cursor: 'pointer' }}
/>
<div
id="sq-apple-pay"
onClick={() => handleWalletPay(applePayRef)}
style={{ display: applePayReady ? 'block' : 'none', cursor: 'pointer' }}
/>
</div>
{/* Divider between wallet buttons and card form */}
{hasWallet && (
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem' }}>
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
<span style={{ fontSize: '0.75rem', color: '#999', whiteSpace: 'nowrap' }}>or pay with card</span>
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
</div>
)}
{/* Accepted card types */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
<button
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={!cardReady || submitting}
onClick={handlePay}
onClick={handleCardPay}
style={{ marginTop: '1rem' }}
>
Place Order · {fmt(payload.grandTotal)}

View File

@ -0,0 +1,47 @@
'use client'
import { useEffect, useState } from 'react'
import { useCart } from '@/context/CartContext'
export default function ScrollToTop() {
const { drawerOpen } = useCart()
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
setScrolled(scrollTop > 130)
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (!scrolled || drawerOpen) return null
return (
<button
aria-label="Back to top"
onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })}
style={{
position: 'fixed',
bottom: '12px',
right: '10px',
zIndex: 98,
border: '1px solid #363636',
outline: 'none',
background: '#94d601',
cursor: 'pointer',
padding: '15px',
borderRadius: '10px',
fontSize: '18px',
boxShadow: '3px 3px 3px #363636',
fontFamily: '"Autour One", serif',
lineHeight: 1,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#aedad3' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#94d601' }}
>
Top
</button>
)
}

View File

@ -13,6 +13,13 @@ export interface CartEntry {
modifierChoices: Record<string, string[]> // listId → optionIds
notes: string
selectedVariationId?: string // set when a non-default variation is chosen
// Vinyl fields — only set when the item has vinylEnabled=true
vinylText?: string
vinylFontId?: string // Square modifier option ID
vinylFontName?: string
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
vinylShapeName?: string
vinylShapePriceCents?: number
}
interface CartState {

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,20 @@ 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
/** When true, the custom vinyl configurator is shown for this item. */
vinylEnabled?: boolean
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
vinylPromo?: boolean
}
export const MOCK_CATALOG: CatalogItem[] = (([
@ -149,5 +163,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[]

31
src/lib/delivery-rates.ts Normal file
View File

@ -0,0 +1,31 @@
import { readFileSync, existsSync } from 'fs'
import path from 'path'
import { atomicWriteJSON } from './file-utils'
import { RATES, type DeliveryRatesConfig, type DeliveryTier } from './delivery'
const RATES_PATH = path.join(process.cwd(), 'data', 'delivery-rates.json')
const TIERS: DeliveryTier[] = ['dropoff', 'classic', 'organic']
export function readDeliveryRates(): DeliveryRatesConfig {
const defaults: DeliveryRatesConfig = {
dropoff: { ...RATES.dropoff },
classic: { ...RATES.classic },
organic: { ...RATES.organic },
}
if (!existsSync(RATES_PATH)) return defaults
try {
const stored = JSON.parse(readFileSync(RATES_PATH, 'utf-8')) as Partial<DeliveryRatesConfig>
const merged = { ...defaults }
for (const tier of TIERS) {
if (stored[tier]) merged[tier] = { ...defaults[tier], ...stored[tier] }
}
return merged
} catch {
return defaults
}
}
export function writeDeliveryRates(config: DeliveryRatesConfig): void {
atomicWriteJSON(RATES_PATH, config)
}

View File

@ -5,7 +5,9 @@ export const SHOP_LNG = -73.0590 // 554 Boston Post Rd, Milford CT
// ── Rates ─────────────────────────────────────────────────────────────────────
export type DeliveryTier = 'dropoff' | 'classic' | 'organic'
export const RATES: Record<DeliveryTier, { base: number; perMile: number; label: string }> = {
export type DeliveryRatesConfig = Record<DeliveryTier, { base: number; perMile: number; label: string }>
export const RATES: DeliveryRatesConfig = {
dropoff: {
base: 20_00, // cents
perMile: 1_60,
@ -111,8 +113,9 @@ export async function calcDelivery(
destLat: number,
destLng: number,
tier: DeliveryTier,
rates?: DeliveryRatesConfig,
): Promise<DeliveryQuote> {
const rate = RATES[tier]
const rate = (rates ?? RATES)[tier]
const { miles: rawMiles, minutes: driveMinutes } =
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
const miles = Math.ceil(rawMiles * 10) / 10

View File

@ -4,11 +4,15 @@ import { atomicWriteJSON } from './file-utils'
export interface ItemOverride {
hidden?: boolean
featured?: boolean
categoryOverride?: string
categoryLabelOverride?: string
/** Replaces categoryOverride — item appears in all listed category tabs (stores label names). */
categoriesOverride?: string[] | null
sortOrder?: number
showColors?: boolean
hiddenModifierIds?: string[]
hiddenVariationIds?: string[]
descriptionOverride?: string
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
modifierMinSelected?: Record<string, number>
@ -18,8 +22,20 @@ export interface ItemOverride {
colorMax?: number
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
chromeSurchargePerColor?: number
/** Color names 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
/** 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
/** When true, shows the custom vinyl text configurator on this item's product modal. */
vinylEnabled?: boolean
/** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */
vinylPromo?: boolean
}
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 }
}, [])
}