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: 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: osrm:
image: osrm/osrm-backend image: osrm/osrm-backend
container_name: osrm 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 withOverrides = items.map((item) => {
const ov = overrides[item.id] ?? {} 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 { return {
...item, ...item,
// Resolved values (what the customer sees) // Resolved values (what the customer sees)
hidden: ov.hidden ?? false, hidden: ov.hidden ?? false,
category: ov.categoryOverride ?? item.category, featured: ov.featured ?? item.featured ?? false,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, ...resolvedCats,
sortOrder: ov.sortOrder ?? 0, sortOrder: ov.sortOrder ?? 0,
showColors: ov.showColors != null ? ov.showColors : item.showColors, showColors: ov.showColors != null ? ov.showColors : item.showColors,
colorMin: ov.colorMin ?? item.colorMin, colorMin: ov.colorMin ?? item.colorMin,
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, 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, description: ov.descriptionOverride ?? item.description,
variations: item.variations.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
modifiers: item.modifiers modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => { .map((m) => {
@ -33,6 +62,7 @@ export async function GET() {
_rawCategory: item.category, _rawCategory: item.category,
_rawCategoryLabel: item.categoryLabel, _rawCategoryLabel: item.categoryLabel,
_rawShowColors: item.showColors, _rawShowColors: item.showColors,
_rawVariations: item.variations,
_rawModifiers: item.modifiers, _rawModifiers: item.modifiers,
_rawDescription: item.description, _rawDescription: item.description,
_override: ov, _override: ov,

View File

@ -3,6 +3,8 @@ import { getCatalog } from '@/lib/catalog-cache'
import { readOverrides } from '@/lib/overrides' import { readOverrides } from '@/lib/overrides'
import type { CatalogItem } from '@/data/mock-catalog' import type { CatalogItem } from '@/data/mock-catalog'
export const dynamic = 'force-dynamic'
function applyOverrides(items: CatalogItem[]): CatalogItem[] { function applyOverrides(items: CatalogItem[]): CatalogItem[] {
const overrides = readOverrides() const overrides = readOverrides()
@ -12,14 +14,44 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
if (!ov) return item if (!ov) return item
return { return {
...item, ...item,
category: ov.categoryOverride ?? item.category, featured: ov.featured ?? item.featured,
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, // 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, showColors: ov.showColors != null ? ov.showColors : item.showColors,
colorMin: ov.colorMin ?? item.colorMin, colorMin: ov.colorMin ?? item.colorMin,
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, 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, description: ov.descriptionOverride ?? item.description,
variations: item.variations
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
modifiers: item.modifiers modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => { .map((m) => {
@ -30,6 +62,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
}) })
.filter((item) => !(overrides[item.id]?.hidden)) .filter((item) => !(overrides[item.id]?.hidden))
.sort((a, b) => { .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 aOrder = overrides[a.id]?.sortOrder ?? 0
const bOrder = overrides[b.id]?.sortOrder ?? 0 const bOrder = overrides[b.id]?.sortOrder ?? 0
return aOrder - bOrder return aOrder - bOrder

View File

@ -1,10 +1,12 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { geocode, calcDelivery, inferTier } from '@/lib/delivery' import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
import { readDeliveryRates } from '@/lib/delivery-rates'
export async function POST(request: Request) { export async function POST(request: Request) {
const { address, itemNames } = await request.json() as { const { address, itemNames, rateOverride } = await request.json() as {
address: string address: string
itemNames: string[] itemNames: string[]
rateOverride?: { base: number; perMile: number }
} }
if (!address?.trim()) { if (!address?.trim()) {
@ -17,7 +19,18 @@ export async function POST(request: Request) {
} }
const tier = inferTier(itemNames ?? []) 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) { if (quote.miles > 40) {
return NextResponse.json( 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 Footer from '@/components/Footer'
import CartDrawer from '@/components/CartDrawer' import CartDrawer from '@/components/CartDrawer'
import CartFab from '@/components/CartFab' import CartFab from '@/components/CartFab'
import ScrollToTop from '@/components/ScrollToTop'
import { CartProvider } from '@/context/CartContext' import { CartProvider } from '@/context/CartContext'
export const metadata: Metadata = { export const metadata: Metadata = {
@ -49,6 +50,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<CartFab /> <CartFab />
<main>{children}</main> <main>{children}</main>
<Footer /> <Footer />
<ScrollToTop />
</CartProvider> </CartProvider>
</body> </body>
</html> </html>

View File

@ -6,6 +6,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import type { ItemOverride } from '@/lib/overrides' import type { ItemOverride } from '@/lib/overrides'
import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config' import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config'
import type { HoursConfig, DayHours } from '@/lib/hours-config' import type { HoursConfig, DayHours } from '@/lib/hours-config'
import AdminColorFilter from '@/components/AdminColorFilter'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem {
_rawCategory: string _rawCategory: string
_rawCategoryLabel: string _rawCategoryLabel: string
_rawShowColors: boolean _rawShowColors: boolean
_rawVariations: CatalogItem['variations']
_rawModifiers: ModifierList[] _rawModifiers: ModifierList[]
_rawDescription: string _rawDescription: string
_override: ItemOverride _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 ────────────────────────────────────────────────────────────── // ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({ function ItemEditor({
@ -594,13 +723,19 @@ function ItemEditor({
}) { }) {
const ov = item._override const ov = item._override
const [hidden, setHidden] = useState(ov.hidden ?? false) const [hidden, setHidden] = useState(ov.hidden ?? false)
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '') const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '') 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 [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
const [showColors, setShowColors] = useState<boolean | null>( const [showColors, setShowColors] = useState<boolean | null>(
ov.showColors != null ? ov.showColors : null ov.showColors != null ? ov.showColors : null
) )
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? []) const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '') const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -623,13 +758,28 @@ function ItemEditor({
const [chromeSurcharge, setChromeSurcharge] = useState<string>( const [chromeSurcharge, setChromeSurcharge] = useState<string>(
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : '' 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 [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 // Create category
const [newCatName, setNewCatName] = useState('') const [newCatName, setNewCatName] = useState('')
const [creatingCat, setCreatingCat] = useState(false) const [creatingCat, setCreatingCat] = useState(false)
const [showNewCat, setShowNewCat] = 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) { function toggleMod(id: string) {
setHiddenMods((prev) => setHiddenMods((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
@ -641,10 +791,14 @@ function ItemEditor({
setError('') setError('')
const patch: Partial<ItemOverride> = { const patch: Partial<ItemOverride> = {
hidden, hidden,
featured,
hiddenVariationIds: hiddenVars,
hiddenModifierIds: hiddenMods, hiddenModifierIds: hiddenMods,
vinylEnabled: vinylEnabled || undefined,
vinylPromo: vinylPromo || undefined,
} }
if (catOverride) patch.categoryOverride = catOverride // Always save categoriesOverride (replaces old single-field overrides)
if (catLabel) patch.categoryLabelOverride = catLabel patch.categoriesOverride = selectedCatNames
if (sortOrder !== '') patch.sortOrder = Number(sortOrder) if (sortOrder !== '') patch.sortOrder = Number(sortOrder)
if (showColors !== null) patch.showColors = showColors if (showColors !== null) patch.showColors = showColors
if (descOverride) patch.descriptionOverride = descOverride if (descOverride) patch.descriptionOverride = descOverride
@ -652,8 +806,12 @@ function ItemEditor({
if (colorMin !== '') patch.colorMin = Number(colorMin) if (colorMin !== '') patch.colorMin = Number(colorMin)
if (colorMax !== '') patch.colorMax = Number(colorMax) if (colorMax !== '') patch.colorMax = Number(colorMax)
if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100) if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100)
patch.disabledColors = disabledColors.length ? disabledColors : undefined
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
else patch.quantityUnit = undefined 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}`, { const res = await fetch(`/api/admin/items/${item.id}`, {
method: 'PATCH', method: 'PATCH',
@ -675,16 +833,23 @@ function ItemEditor({
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' }) const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
setHidden(false) setHidden(false)
setCatOverride('') setFeatured(item.featured ?? false)
setCatLabel('') setVinylEnabled(false)
setVinylPromo(false)
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
setSortOrder('') setSortOrder('')
setShowColors(null) setShowColors(null)
setHiddenVars([])
setHiddenMods([]) setHiddenMods([])
setDescOverride('') setDescOverride('')
setModifierMins({}) setModifierMins({})
setColorMin('') setColorMin('')
setColorMax('') setColorMax('')
setChromeSurcharge('') setChromeSurcharge('')
setDisabledColors([])
setRequiresDelivery(false)
setDeliveryBase('')
setDeliveryPerMile('')
onSaved(item.id, {}) onSaved(item.id, {})
} }
} }
@ -707,15 +872,13 @@ function ItemEditor({
if (!newCatName.trim()) return if (!newCatName.trim()) return
setCreatingCat(true) setCreatingCat(true)
const cat = await onCreateCategory(newCatName.trim()) const cat = await onCreateCategory(newCatName.trim())
setCatOverride(cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) // Auto-select the newly created category
setCatLabel(cat.name) if (cat.id) setSelectedCatNames((prev) => [...prev, cat.name])
setNewCatName('') setNewCatName('')
setShowNewCat(false) setShowNewCat(false)
setCreatingCat(false) setCreatingCat(false)
} }
const catSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return ( return (
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}> <div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
<div className="columns is-multiline"> <div className="columns is-multiline">
@ -723,8 +886,8 @@ function ItemEditor({
{/* Left column */} {/* Left column */}
<div className="column is-half"> <div className="column is-half">
{/* Hidden toggle */} {/* Visibility toggles */}
<div className="field"> <div className="field" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
<label className="checkbox" style={{ fontWeight: 600 }}> <label className="checkbox" style={{ fontWeight: 600 }}>
<input <input
type="checkbox" type="checkbox"
@ -734,37 +897,121 @@ function ItemEditor({
/> />
Hidden from storefront Hidden from storefront
</label> </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> </div>
{/* Category */} {requiresDelivery && (
<div className="field"> <div className="field" style={{ background: '#fff8f8', border: '1px solid #f5c6c6', borderRadius: 6, padding: '0.6rem 0.8rem', marginBottom: '0.75rem' }}>
<label className="label is-small">Category</label> <p className="is-size-7 has-text-grey" style={{ marginBottom: '0.4rem' }}>
<div className="control"> Custom delivery rates for this item (leave blank to use global tier defaults)
<div className="select is-small is-fullwidth"> </p>
<select <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
value={catOverride || item._rawCategory} <div>
onChange={(e) => { <label className="label is-small" style={{ marginBottom: 2 }}>Base charge ($)</label>
const selected = categories.find((c) => catSlug(c.name) === e.target.value) <input
setCatOverride(e.target.value) className="input is-small"
setCatLabel(selected?.name ?? e.target.value) type="number"
}} min="0"
> step="0.01"
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option> placeholder="e.g. 75.00"
{categories value={deliveryBase}
.filter((c) => catSlug(c.name) !== item._rawCategory) onChange={(e) => setDeliveryBase(e.target.value)}
.map((c) => ( style={{ width: 110 }}
<option key={c.id} value={catSlug(c.name)}>{c.name}</option> />
))} </div>
</select> <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>
</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 <button
className="button is-ghost is-small" className="button is-ghost is-small"
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }} style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
onClick={() => setShowNewCat(!showNewCat)} onClick={() => setShowNewCat(!showNewCat)}
type="button" type="button"
> >
+ Create new category + Create new category in Square
</button> </button>
{showNewCat && ( {showNewCat && (
<div className="field has-addons" style={{ marginTop: 6 }}> <div className="field has-addons" style={{ marginTop: 6 }}>
@ -858,6 +1105,42 @@ function ItemEditor({
{/* Right column */} {/* Right column */}
<div className="column is-half"> <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 */} {/* Modifiers */}
{item._rawModifiers.length > 0 && ( {item._rawModifiers.length > 0 && (
<div className="field"> <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. 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.`} {item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`}
</p> </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> </div>
)} )}
{showColorFilter && (
<AdminColorFilter
disabledColors={disabledColors}
onSave={setDisabledColors}
onClose={() => setShowColorFilter(false)}
/>
)}
{/* Quantity unit */} {/* Quantity unit */}
<div className="field" style={{ marginTop: '1rem' }}> <div className="field" style={{ marginTop: '1rem' }}>
<label className="label is-small">Quantity unit</label> <label className="label is-small">Quantity unit</label>
@ -1204,7 +1515,7 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [search, setSearch] = 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 [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('') const [refreshMsg, setRefreshMsg] = useState('')
@ -1354,6 +1665,9 @@ export default function AdminPage() {
<li className={tab === 'occasions' ? 'is-active' : ''}> <li className={tab === 'occasions' ? 'is-active' : ''}>
<a onClick={() => setTab('occasions')}>Holidays</a> <a onClick={() => setTab('occasions')}>Holidays</a>
</li> </li>
<li className={tab === 'delivery' ? 'is-active' : ''}>
<a onClick={() => setTab('delivery')}>Delivery rates</a>
</li>
</ul> </ul>
</div> </div>
@ -1456,6 +1770,9 @@ export default function AdminPage() {
{/* Holidays tab */} {/* Holidays tab */}
{tab === 'occasions' && <OccasionsEditor />} {tab === 'occasions' && <OccasionsEditor />}
{/* Delivery rates tab */}
{tab === 'delivery' && <DeliveryRatesEditor />}
</div> </div>
</section> </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 // Unit price — uses selected variation price if set, otherwise product default
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => { 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 const base = entry.selectedVariationId
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0)) ? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
: (entry.product.price ?? 0) : (entry.product.price ?? 0)
@ -151,23 +155,53 @@ export default function CartDrawer() {
const grandTotal = subtotal + deliveryTotal + taxCents const grandTotal = subtotal + deliveryTotal + taxCents
// Build the payload sent to /api/checkout (recomputed only when dependencies change) // Build the payload sent to /api/checkout (recomputed only when dependencies change)
type LI = CheckoutPayload['lineItems'][number]
const checkoutPayload = useMemo<CheckoutPayload>(() => ({ const checkoutPayload = useMemo<CheckoutPayload>(() => ({
lineItems: entries.map((e) => ({ lineItems: entries.flatMap((e): LI[] => {
name: e.product.name, if (e.vinylText && e.vinylShapeVariationId) {
quantity: e.quantity, const letterCount = e.vinylText.replace(/ /g, '').length
priceCents: entryUnitPrice(e), const vinylCents = letterCount * 65
catalogItemId: e.selectedVariationId ?? e.product.id, return [
colors: e.selectedColors.length ? e.selectedColors : undefined, {
note: e.notes || undefined, name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { quantity: e.quantity,
const ml = e.product.modifiers.find((m) => m.id === listId) priceCents: e.vinylShapePriceCents ?? 450,
if (!ml) return [] catalogItemId: e.vinylShapeVariationId,
return optIds.map((optId) => ({ note: 'For custom vinyl',
catalogObjectId: optId, },
name: ml.options.find((o) => o.id === optId)?.name ?? optId, {
})) 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), selectedColors: entries.flatMap((e) => e.selectedColors),
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined, deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : 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> <span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
)} )}
</div> </div>
{entry.selectedColors.length > 0 && ( {entry.vinylText ? (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}> <div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
Colors: {entry.selectedColors.join(', ')} <div>Shape: 18&quot; {entry.vinylShapeName}</div>
</div> <div>Text: &ldquo;{entry.vinylText}&rdquo;</div>
)} {entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => { {entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>&ldquo;{entry.notes}&rdquo;</div>}
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>
) : (
<>
{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> </div>
)) ))

View File

@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import { useCart } from '@/context/CartContext' import { useCart } from '@/context/CartContext'
import type { CartEntry } from '@/context/CartContext' import type { CartEntry } from '@/context/CartContext'
import { fmt } from '@/lib/format' import { fmt } from '@/lib/format'
import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route'
interface ColorEntry { interface ColorEntry {
name: string name: string
@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
() => editingEntry?.selectedVariationId ?? product.variations[0]?.id () => 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(() => { useEffect(() => {
fetch('/colors.json') fetch('/colors.json')
.then((r) => r.json()) .then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data)) .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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
return ml.minSelected > 0 && chosen < ml.minSelected return ml.minSelected > 0 && chosen < ml.minSelected
}) })
const needsColors = product.showColors && selected.size < colorMin 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) // Selectable variations: everything except the chrome variation (auto-applied by color choice)
const selectableVariations = product.variations.filter( const selectableVariations = product.variations.filter(
@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
}, 0) }, 0)
const basePrice = activeVariation?.priceCents ?? product.price ?? 0 const basePrice = activeVariation?.priceCents ?? product.price ?? 0
const chromeDelta = chromeCount * surchargePerColor const chromeDelta = chromeCount * surchargePerColor
const unitPrice = basePrice + modDelta + chromeDelta const unitPrice = product.vinylEnabled
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote' ? vinylPriceCents
: basePrice + modDelta + chromeDelta
const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote'
return ( return (
<div className="modal is-active" onClick={onClose}> <div className="modal is-active" onClick={onClose}>
@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</p> </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 ── */} {/* ── Size / variation selector ── */}
{selectableVariations.length > 1 && ( {selectableVariations.length > 1 && (
<div className="field" style={{ marginBottom: '1.25rem' }}> <div className="field" style={{ marginBottom: '1.25rem' }}>
@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</div> </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 ── */} {/* ── Notes ── */}
<div className="field mt-4"> <div className="field mt-4">
<label className="label">Special notes (optional)</label> <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' }}> <footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && ( {!canAdd && (
<p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}> <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, needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
...missingModifiers.map((ml) => ml.name), needsVinylText ? 'enter your vinyl message' : null,
needsVinylFont ? 'choose a font style' : null,
...missingModifiers.map((ml) => `choose ${ml.name}`),
].filter(Boolean).join(', ')} ].filter(Boolean).join(', ')}
</p> </p>
)} )}
@ -544,12 +707,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
if (v.size) choices[k] = Array.from(v) if (v.size) choices[k] = Array.from(v)
}) })
const selectedVariationId = activeVariation?.id 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 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) { 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 { } else {
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId }) addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
} }
onClose() onClose()
}} }}

View File

@ -47,6 +47,12 @@ export default function FeaturedProducts() {
setShowTour(true) setShowTour(true)
} }
const tourInit = () => {
setCategory('all')
setSearch('')
setSearchOpen(false)
}
const endTour = () => { const endTour = () => {
setShowTour(false) setShowTour(false)
// Close any customization modal that may have been opened during the tour // Close any customization modal that may have been opened during the tour
@ -56,7 +62,11 @@ export default function FeaturedProducts() {
const productCategories = useMemo(() => { const productCategories = useMemo(() => {
const seen = new Map<string, string>() const seen = new Map<string, string>()
items.forEach((item) => { items.forEach((item) => {
if (!seen.has(item.category)) seen.set(item.category, item.categoryLabel) const cats = item.categories ?? [item.category]
const labels = item.categoryLabels ?? [item.categoryLabel]
cats.forEach((slug, i) => {
if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug)
})
}) })
const all = Array.from(seen.entries()).map(([key, label]) => ({ key, label })) const all = Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
const visible = all.filter((c) => !catHidden.includes(c.key)) const visible = all.filter((c) => !catHidden.includes(c.key))
@ -71,11 +81,15 @@ export default function FeaturedProducts() {
return visible return visible
}, [items, catOrder, catHidden]) }, [items, catOrder, catHidden])
const tabs = useMemo(() => [ const tabs = useMemo(() => {
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })), // Category slugs already represented by an occasion tab — hide them from the regular tabs
{ key: 'all', label: 'All', occasion: false }, const occasionSlugs = new Set(activeOccasions.map((o) => o.squareCategorySlug).filter(Boolean) as string[])
...productCategories.map((c) => ({ ...c, occasion: false })), return [
], [activeOccasions, productCategories]) ...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( const activeOccasion: ActiveOccasion | undefined = useMemo(
() => activeOccasions.find((o) => o.key === category), () => activeOccasions.find((o) => o.key === category),
@ -115,11 +129,11 @@ export default function FeaturedProducts() {
const filtered = (activeOccasion const filtered = (activeOccasion
? activeOccasion.squareCategorySlug ? activeOccasion.squareCategorySlug
? items.filter((i) => i.category === activeOccasion.squareCategorySlug) ? items.filter((i) => (i.categories ?? [i.category]).includes(activeOccasion.squareCategorySlug!))
: items : items
: category === 'all' : category === 'all'
? items ? items
: items.filter((i) => i.category === category) : items.filter((i) => (i.categories ?? [i.category]).includes(category))
).filter((i) => ).filter((i) =>
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q) !q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
) )
@ -182,7 +196,7 @@ export default function FeaturedProducts() {
placeholder="Search…" placeholder="Search…"
value={search} value={search}
autoFocus autoFocus
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); if (e.target.value) setCategory('all') }}
onBlur={() => { if (!search) setSearchOpen(false) }} onBlur={() => { if (!search) setSearchOpen(false) }}
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }} onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
style={{ width: '160px' }} style={{ width: '160px' }}
@ -223,7 +237,7 @@ export default function FeaturedProducts() {
{/* Welcome modal + guided tour */} {/* Welcome modal + guided tour */}
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />} {showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
{showTour && <GuidedTour onDone={endTour} />} {showTour && <GuidedTour onDone={endTour} onStart={tourInit} />}
{/* Product grid */} {/* Product grid */}
{loading ? ( {loading ? (

View File

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

View File

@ -1,9 +1,9 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef import { useEffect, useRef, useState } from 'react'
import { fmt } from '@/lib/format' import { fmt } from '@/lib/format'
// ── Minimal Square Web Payments SDK types ───────────────────────────────────── // ── Square Web Payments SDK types ─────────────────────────────────────────────
declare global { declare global {
interface Window { interface Window {
Square?: { 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 { interface SquarePayments {
card(options?: object): Promise<SquareCard> card(options?: object): Promise<SquareCard>
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
} }
interface SquareCard { interface SquareCard {
attach(selector: string): Promise<void> attach(selector: string): Promise<void>
tokenize(): Promise<{ tokenize(): Promise<TokenResult>
status: string
token?: string
errors?: Array<{ message: string }>
}>
destroy(): Promise<void> 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 { 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 appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
const [sdkReady, setSdkReady] = useState(false) const [sdkReady, setSdkReady] = useState(false)
const [cardReady, setCardReady] = useState(false) const [cardReady, setCardReady] = useState(false)
const [submitting, setSubmitting] = useState(false) const [googlePayReady, setGooglePayReady] = useState(false)
const [error, setError] = useState('') const [applePayReady, setApplePayReady] = useState(false)
const cardRef = useRef<SquareCard | null>(null) 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) // 1 — Load Square SDK script (idempotent)
useEffect(() => { useEffect(() => {
@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
// Clear any previous error when the booking slot changes // Clear any previous error when the booking slot changes
useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO]) 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(() => { useEffect(() => {
if (!active || !sdkReady || !window.Square || !appId || !locationId) return if (!active || !sdkReady || !window.Square || !appId || !locationId) return
if (cardRef.current) return // already initialised if (cardRef.current) return // already initialised
@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
;(async () => { ;(async () => {
try { 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) => await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => 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 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') await card.attach('#sq-card')
if (mounted) { if (mounted) { cardRef.current = card; setCardReady(true) }
cardRef.current = card else { card.destroy().catch(() => {}); return }
setCardReady(true)
} else { // Google Pay (available in Chrome and most modern browsers)
card.destroy().catch(() => {}) 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) { } catch (e) {
console.error('[PaymentForm] init:', e) console.error('[PaymentForm] init:', e)
if (mounted) setError('Could not load payment form — please refresh and try again.') 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?.destroy().catch(() => {})
cardRef.current = null cardRef.current = null
setCardReady(false) setCardReady(false)
googlePayRef.current?.destroy().catch(() => {})
googlePayRef.current = null
setGooglePayReady(false)
applePayRef.current?.destroy().catch(() => {})
applePayRef.current = null
setApplePayReady(false)
} }
}, [active, sdkReady, appId, locationId]) }, [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 if (!cardRef.current || submitting) return
setSubmitting(true) setSubmitting(true)
setError('') setError('')
try { try {
const tokenResult = await cardRef.current.tokenize() const tokenResult = await cardRef.current.tokenize()
if (tokenResult.status !== 'OK' || !tokenResult.token) { if (tokenResult.status !== 'OK' || !tokenResult.token) {
setError( setError(
tokenResult.errors?.map((e) => e.message).join(' ') ?? tokenResult.errors?.map((e) => e.message).join(' ') ??
@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
) )
return return
} }
await submitToken(tokenResult.token)
} finally {
setSubmitting(false)
}
}
const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token }) const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => {
if (!walletRef.current || submitting) return
// Attempt the checkout request. On a network-level failure (fetch throws), setSubmitting(true)
// wait 2 seconds and retry once automatically — the idempotency key ensures setError('')
// no double charge and will return success if the first attempt already try {
// captured payment but the response was lost. const tokenResult = await walletRef.current.tokenize()
const attemptCheckout = () => fetch('/api/checkout', { if (tokenResult.status !== 'OK' || !tokenResult.token) {
method: 'POST', // 'Cancel' means the user dismissed the native sheet — not an error
headers: { 'Content-Type': 'application/json' }, if (tokenResult.status !== 'Cancel') {
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.
setError( setError(
'Connection issue — your payment may have already been processed. ' + tokenResult.errors?.map((e) => e.message).join(' ') ??
'Please tap "Place Order" again to confirm, or contact us if this persists.' '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 return
} }
await submitToken(tokenResult.token)
onSuccess(data.orderId as string, data.shortRef as string)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
) )
} }
const hasWallet = googlePayReady || applePayReady
return ( return (
<div> <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 */} {/* Accepted card types */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => ( {['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
<button <button
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`} className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={!cardReady || submitting} disabled={!cardReady || submitting}
onClick={handlePay} onClick={handleCardPay}
style={{ marginTop: '1rem' }} style={{ marginTop: '1rem' }}
> >
Place Order · {fmt(payload.grandTotal)} 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 modifierChoices: Record<string, string[]> // listId → optionIds
notes: string notes: string
selectedVariationId?: string // set when a non-default variation is chosen 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 { interface CartState {

View File

@ -28,6 +28,9 @@ export interface CatalogItem {
description: string description: string
category: string category: string
categoryLabel: string categoryLabel: string
/** All display categories this item belongs to (multi-category support). First entry matches category/categoryLabel. */
categories: string[]
categoryLabels: string[]
/** Price in cents of the default variation. null = custom quote required. */ /** Price in cents of the default variation. null = custom quote required. */
price: number | null price: number | null
imageUrl: string | null imageUrl: string | null
@ -39,9 +42,20 @@ export interface CatalogItem {
colorMin: number // minimum colors required when showColors=true (default 1) colorMin: number // minimum colors required when showColors=true (default 1)
colorMax: number | null // maximum colors allowed (null = unlimited) colorMax: number | null // maximum colors allowed (null = unlimited)
chromeSurchargePerColor: number // extra cents per chrome color selected (0 = flat chrome variation instead) 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 variations: CatalogVariation[] // all enabled variations; first is the default
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */ /** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
quantityUnit?: string 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[] = (([ export const MOCK_CATALOG: CatalogItem[] = (([
@ -149,5 +163,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([
chromeSurchargePerColor: 0, chromeSurchargePerColor: 0,
imageUrls: item.imageUrl ? [item.imageUrl] : [], imageUrls: item.imageUrl ? [item.imageUrl] : [],
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [], variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
categories: [item.category],
categoryLabels: [item.categoryLabel],
...item, ...item,
})) as CatalogItem[] })) as CatalogItem[]

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

View File

@ -4,11 +4,15 @@ import { atomicWriteJSON } from './file-utils'
export interface ItemOverride { export interface ItemOverride {
hidden?: boolean hidden?: boolean
featured?: boolean
categoryOverride?: string categoryOverride?: string
categoryLabelOverride?: string categoryLabelOverride?: string
/** Replaces categoryOverride — item appears in all listed category tabs (stores label names). */
categoriesOverride?: string[] | null
sortOrder?: number sortOrder?: number
showColors?: boolean showColors?: boolean
hiddenModifierIds?: string[] hiddenModifierIds?: string[]
hiddenVariationIds?: string[]
descriptionOverride?: string descriptionOverride?: string
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */ /** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
modifierMinSelected?: Record<string, number> modifierMinSelected?: Record<string, number>
@ -18,8 +22,20 @@ export interface ItemOverride {
colorMax?: number colorMax?: number
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */ /** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
chromeSurchargePerColor?: number 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". */ /** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
quantityUnit?: string 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> export type OverridesMap = Record<string, ItemOverride>

View File

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

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 }
}, [])
}