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>
This commit is contained in:
chris 2026-04-29 16:27:27 -04:00
parent b1606302b0
commit e7fec9ea72
16 changed files with 751 additions and 71 deletions

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,16 +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, vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled,
vinylPromo: ov.vinylPromo ?? item.vinylPromo, 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) => {
@ -32,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

@ -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,15 +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 [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false) const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false) const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '') const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '') // 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)
@ -625,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]
@ -643,12 +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, vinylEnabled: vinylEnabled || undefined,
vinylPromo: vinylPromo || 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
@ -656,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',
@ -679,18 +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)
setFeatured(item.featured ?? false)
setVinylEnabled(false) setVinylEnabled(false)
setVinylPromo(false) setVinylPromo(false)
setCatOverride('') setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
setCatLabel('')
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, {})
} }
} }
@ -713,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">
@ -729,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"
@ -740,8 +897,62 @@ 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>
{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 */} {/* Vinyl options */}
<div className="field"> <div className="field">
<label className="checkbox"> <label className="checkbox">
@ -768,27 +979,31 @@ function ItemEditor({
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p> <p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
</div> </div>
{/* Category */} {/* Category — multi-select checkboxes */}
<div className="field"> <div className="field">
<label className="label is-small">Category</label> <label className="label is-small">Categories <span className="has-text-grey-light" style={{ fontWeight: 'normal' }}>(item appears in all checked tabs)</span></label>
<div className="control"> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto', border: '1px solid #e8e8e8', borderRadius: 6, padding: '6px 8px' }}>
<div className="select is-small is-fullwidth"> {categories.map((c) => (
<select <label key={c.id} className="checkbox" style={{ fontSize: '0.85rem' }}>
value={catOverride || item._rawCategory} <input
onChange={(e) => { type="checkbox"
const selected = categories.find((c) => catSlug(c.name) === e.target.value) checked={selectedCatNames.includes(c.name)}
setCatOverride(e.target.value) onChange={(e) => {
setCatLabel(selected?.name ?? e.target.value) setSelectedCatNames((prev) =>
}} e.target.checked ? [...prev, c.name] : prev.filter((n) => n !== c.name)
> )
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option> }}
{categories style={{ marginRight: 6 }}
.filter((c) => catSlug(c.name) !== item._rawCategory) />
.map((c) => ( {c.name}
<option key={c.id} value={catSlug(c.name)}>{c.name}</option> {(item.categoryLabels ?? [item.categoryLabel]).includes(c.name) && (
))} <span className="has-text-grey-light" style={{ fontSize: '0.72rem', marginLeft: 6 }}>Square</span>
</select> )}
</div> </label>
))}
{categories.length === 0 && (
<p className="is-size-7 has-text-grey">No categories found refresh from Square.</p>
)}
</div> </div>
<button <button
className="button is-ghost is-small" className="button is-ghost is-small"
@ -796,7 +1011,7 @@ function ItemEditor({
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 }}>
@ -890,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">
@ -1036,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>
@ -1236,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('')
@ -1386,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>
@ -1488,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

@ -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

@ -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

@ -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,16 @@ 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. */ /** When true, the custom vinyl configurator is shown for this item. */
vinylEnabled?: boolean vinylEnabled?: boolean
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */ /** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
@ -153,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,16 @@ 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. */ /** When true, shows the custom vinyl text configurator on this item's product modal. */
vinylEnabled?: boolean vinylEnabled?: boolean
/** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */ /** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */

View File

@ -155,20 +155,22 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
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: displayCatName,
price: priceAmount ? Number(priceAmount) : null, categories: [categorySlug],
categoryLabels: [displayCatName],
price: priceAmount ? Number(priceAmount) : null,
imageUrl, imageUrl,
imageUrls, imageUrls,
featured: false, featured: false,
tags: [], tags: [],
modifiers, modifiers,
showColors: hasLatexColors, showColors: hasLatexColors,
colorMin: 1, colorMin: 1,
colorMax: null, colorMax: null,
chromeSurchargePerColor: 0, chromeSurchargePerColor: 0,
variations, variations,
} }

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