'use client' import { BASE } from '@/lib/basepath' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useRouter } from 'next/navigation' import type { CatalogItem, ModifierList } from '@/data/mock-catalog' import type { ItemOverride } from '@/lib/overrides' import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config' import type { HoursConfig, DayHours } from '@/lib/hours-config' import AdminColorFilter from '@/components/AdminColorFilter' // ─── Types ──────────────────────────────────────────────────────────────────── interface AdminItem extends CatalogItem { hidden: boolean sortOrder: number _rawCategory: string _rawCategoryLabel: string _rawShowColors: boolean _rawVariations: CatalogItem['variations'] _rawModifiers: ModifierList[] _rawDescription: string _override: ItemOverride } interface SquareCategory { id: string; name: string } // ─── Helpers ────────────────────────────────────────────────────────────────── function cents(n: number | null) { if (n == null) return 'Custom quote' return `$${(n / 100).toFixed(2)}` } // ─── Category Display Editor ────────────────────────────────────────────────── function CategoryDisplayEditor({ items }: { items: AdminItem[] }) { const [rows, setRows] = useState<{ key: string; label: string; hidden: boolean }[]>([]) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') // Derive unique display categories from items — same logic as the shop front-end const catalogCats = useMemo(() => { const seen = new Map() items.forEach((item) => { const cats = item.categories ?? [item.category] const labels = item.categoryLabels ?? [item.categoryLabel] cats.forEach((slug, i) => { if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug) }) }) return Array.from(seen.entries()).map(([key, label]) => ({ key, label })) }, [items]) useEffect(() => { if (!catalogCats.length) return fetch(BASE + '/api/admin/categories-display') .then((r) => r.json()) .then(({ order, hidden }: { order: string[]; hidden: string[] }) => { const inOrder = order .map((k) => catalogCats.find((c) => c.key === k)) .filter((c): c is typeof catalogCats[number] => !!c) .map((c) => ({ ...c, hidden: hidden.includes(c.key) })) const rest = catalogCats .filter((c) => !order.includes(c.key)) .map((c) => ({ ...c, hidden: hidden.includes(c.key) })) setRows([...inOrder, ...rest]) }) .catch(() => setRows(catalogCats.map((c) => ({ ...c, hidden: false })))) }, [catalogCats]) function move(index: number, dir: -1 | 1) { const next = [...rows] const swap = index + dir if (swap < 0 || swap >= next.length) return ;[next[index], next[swap]] = [next[swap], next[index]] setRows(next) } function toggleHidden(key: string) { setRows((prev) => prev.map((r) => r.key === key ? { ...r, hidden: !r.hidden } : r)) } async function handleSave() { setSaving(true) setMsg('') const res = await fetch(BASE + '/api/admin/categories-display', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: rows.map((r) => r.key), hidden: rows.filter((r) => r.hidden).map((r) => r.key), }), }) setSaving(false) setMsg(res.ok ? 'Saved' : 'Save failed') setTimeout(() => setMsg(''), 3000) } if (!rows.length) return null return (

Tab order & visibility

Controls which category tabs appear in the shop and in what order. Hidden categories still appear on individual items.

{rows.map((row, i) => ( ))}
{row.label}
{msg && ( {msg} )}
) } // ─── Hours Editor ───────────────────────────────────────────────────────────── const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] function HoursEditor() { const [config, setConfig] = useState(null) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') useEffect(() => { fetch(BASE + '/api/admin/hours') .then((r) => r.json()) .then(setConfig) .catch(() => setConfig(DEFAULT_HOURS)) }, []) function setDay(type: 'delivery' | 'pickup', dow: number, value: DayHours | null) { setConfig((prev) => { if (!prev) return prev return { ...prev, [type]: { ...prev[type], [String(dow)]: value } } }) } async function handleSave() { if (!config) return setSaving(true) setMsg('') const res = await fetch(BASE + '/api/admin/hours', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }) setSaving(false) setMsg(res.ok ? 'Saved' : 'Save failed') setTimeout(() => setMsg(''), 3000) } if (!config) return

Loading…

return (
{(['delivery', 'pickup'] as const).map((type) => (

{type}

{DAY_NAMES.map((dayName, dow) => { const hours = config[type][String(dow)] ?? null const isOpen = hours !== null return ( ) })}
Day Open Opens at Closes at
{dayName} { if (!isOpen) return setDay(type, dow, { ...hours!, open: timeToMins(e.target.value) }) }} style={{ width: 110 }} /> { if (!isOpen) return setDay(type, dow, { ...hours!, close: timeToMins(e.target.value) }) }} style={{ width: 110 }} />
))}
{msg && ( {msg} )}

Changes take effect immediately. Delivery slots already visible to customers are not affected until they reload.

) } // ─── Occasions Editor ───────────────────────────────────────────────────────── interface OccasionRow { key: string label: string emoji: string defaultSlug: string isCustom: boolean enabled: boolean squareCategorySlug: string windowStart: string | null windowEnd: string | null windowStartOverridden: boolean windowEndOverridden: boolean defaultWindowStart: string | null defaultWindowEnd: string | null blurb?: string } const EMPTY_NEW = { emoji: '', label: '', blurb: '', squareCategorySlug: '', windowStart: '', windowEnd: '' } function OccasionsEditor() { const [rows, setRows] = useState([]) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') const [showForm, setShowForm] = useState(false) const [newOcc, setNewOcc] = useState(EMPTY_NEW) useEffect(() => { fetch(BASE + '/api/admin/occasions') .then((r) => r.json()) .then(({ occasions }: { occasions: OccasionRow[] }) => setRows(occasions)) .catch(() => {}) }, []) function update(key: string, patch: Partial) { setRows((prev) => prev.map((r) => r.key === key ? { ...r, ...patch } : r)) } function deleteRow(key: string) { setRows((prev) => prev.filter((r) => r.key !== key)) } function addCustom() { if (!newOcc.label || !newOcc.windowStart || !newOcc.windowEnd) return const key = `custom_${newOcc.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}_${Date.now()}` const row: OccasionRow = { key, label: newOcc.label, emoji: newOcc.emoji || '🎉', defaultSlug: '', isCustom: true, enabled: true, squareCategorySlug: newOcc.squareCategorySlug, windowStart: newOcc.windowStart, windowEnd: newOcc.windowEnd, windowStartOverridden: true, windowEndOverridden: true, defaultWindowStart: null, defaultWindowEnd: null, blurb: newOcc.blurb, } setRows((prev) => [...prev, row]) setNewOcc(EMPTY_NEW) setShowForm(false) } async function handleSave() { setSaving(true) setMsg('') const config: Record = {} for (const r of rows) { if (r.isCustom) { config[r.key] = { custom: true, enabled: r.enabled, label: r.label, emoji: r.emoji, blurb: r.blurb ?? '', squareCategorySlug: r.squareCategorySlug || undefined, windowStart: (r.windowStart ?? '').slice(5), // YYYY-MM-DD → MM-DD windowEnd: (r.windowEnd ?? '').slice(5), } } else { const ov: Record = {} if (!r.enabled) ov.enabled = false if (r.squareCategorySlug !== r.defaultSlug) ov.squareCategorySlug = r.squareCategorySlug if (r.windowStartOverridden && r.windowStart) ov.windowStart = r.windowStart.slice(5) if (r.windowEndOverridden && r.windowEnd) ov.windowEnd = r.windowEnd.slice(5) if (Object.keys(ov).length) config[r.key] = ov } } const res = await fetch(BASE + '/api/admin/occasions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }) setSaving(false) setMsg(res.ok ? 'Saved' : 'Save failed') setTimeout(() => setMsg(''), 3000) } if (!rows.length) return

Loading…

return (

Control which holiday tabs appear in the shop, which Square category they filter, and the date window during which the tab is shown.

{rows.map((row) => ( ))}
Holiday Show Square category slug Window opens Window closes
{row.emoji} {row.label} update(row.key, { squareCategorySlug: e.target.value })} style={{ width: '100%', maxWidth: 210 }} /> {row.defaultSlug && row.squareCategorySlug !== row.defaultSlug && (
default: {row.defaultSlug}
)}
update(row.key, { windowStart: e.target.value, windowStartOverridden: true })} style={{ width: 130 }} /> {!row.isCustom && row.windowStartOverridden && row.windowStart !== row.defaultWindowStart && (
{!row.isCustom && !row.windowStartOverridden && (
computed
)}
update(row.key, { windowEnd: e.target.value, windowEndOverridden: true })} style={{ width: 130 }} /> {!row.isCustom && row.windowEndOverridden && row.windowEnd !== row.defaultWindowEnd && (
{!row.isCustom && !row.windowEndOverridden && (
computed
)}
{row.isCustom && (
{/* Add new custom holiday */} {showForm ? (

New holiday

setNewOcc((p) => ({ ...p, emoji: e.target.value }))} style={{ width: 60 }} placeholder="🎉" />
setNewOcc((p) => ({ ...p, label: e.target.value }))} placeholder="Summer Sale" />
setNewOcc((p) => ({ ...p, blurb: e.target.value }))} placeholder="Browse our summer collection." />
setNewOcc((p) => ({ ...p, squareCategorySlug: e.target.value }))} placeholder="summer-sale" />
setNewOcc((p) => ({ ...p, windowStart: e.target.value }))} style={{ width: 140 }} />
setNewOcc((p) => ({ ...p, windowEnd: e.target.value }))} style={{ width: 140 }} />
) : ( )}
{msg && ( {msg} )}

Dates showing "computed" are auto-calculated each year (e.g. Easter, nth weekday). Click the date to override; the × resets to computed. Changes take effect immediately.

) } // ─── Delivery Rates Editor ──────────────────────────────────────────────────── interface TierRate { base: number; perMile: number; label: string } interface DeliveryRatesConfig { dropoff: TierRate; classic: TierRate; organic: TierRate } const TIER_LABELS: Record = { dropoff: 'Drop-off', classic: 'Setup & strike', organic: 'Organic setup & strike', } function DeliveryRatesEditor() { const [rates, setRates] = useState(null) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') useEffect(() => { fetch(BASE + '/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(BASE + '/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

Loading…

return (

Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.

{(['dropoff', 'classic', 'organic'] as const).map((tier) => ( ))}
Tier Base fee ($) Per mile ($) Label
{TIER_LABELS[tier]} updateTier(tier, 'base', e.target.value)} style={{ width: 100 }} /> updateTier(tier, 'perMile', e.target.value)} style={{ width: 100 }} /> updateTier(tier, 'label', e.target.value)} style={{ width: '100%' }} />

Formula: base + ceil(miles) × per-mile. Example: drop-off to a 5-mile address = {' '}base + 5 × per-mile.

{msg && ( {msg} )}
) } // ─── Item Editor ────────────────────────────────────────────────────────────── function ItemEditor({ item, categories, onSaved, onCreateCategory, }: { item: AdminItem categories: SquareCategory[] onSaved: (id: string, ov: Partial) => void onCreateCategory: (name: string) => Promise }) { const ov = item._override const [hidden, setHidden] = useState(ov.hidden ?? false) const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false) // Multi-category selection: stores category names (labels). Initialise from new override or fall back to Square assignment. const [selectedCatNames, setSelectedCatNames] = useState( ov.categoriesOverride ?? item.categoryLabels ?? [item.categoryLabel] ) const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? '')) const [showColors, setShowColors] = useState( ov.showColors != null ? ov.showColors : null ) const [hiddenVars, setHiddenVars] = useState(ov.hiddenVariationIds ?? []) const [hiddenMods, setHiddenMods] = useState(ov.hiddenModifierIds ?? []) const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [error, setError] = useState('') // Per-modifier min overrides const [modifierMins, setModifierMins] = useState>( ov.modifierMinSelected ?? {} ) // Image upload const fileInputRef = useRef(null) const [uploading, setUploading] = useState(false) const [uploadResults, setUploadResults] = useState<{ name: string; url?: string | null; error?: string }[]>([]) // Color limits const [colorMin, setColorMin] = useState(String(ov.colorMin ?? '')) const [colorMax, setColorMax] = useState(ov.colorMax != null ? String(ov.colorMax) : '') const [chromeSurcharge, setChromeSurcharge] = useState( ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : '' ) const [disabledColors, setDisabledColors] = useState(ov.disabledColors ?? []) const [showColorFilter, setShowColorFilter] = useState(false) const [quantityUnit, setQuantityUnit] = useState(ov.quantityUnit ?? '') const [requiresDelivery, setRequiresDelivery] = useState(ov.requiresDelivery ?? false) const [deliveryBase, setDeliveryBase] = useState( ov.deliveryBaseOverride != null ? String(ov.deliveryBaseOverride / 100) : '' ) const [deliveryPerMile, setDeliveryPerMile] = useState( ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : '' ) // Create category const [newCatName, setNewCatName] = useState('') const [creatingCat, setCreatingCat] = useState(false) const [showNewCat, setShowNewCat] = useState(false) function toggleVar(id: string) { setHiddenVars((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] ) } function toggleMod(id: string) { setHiddenMods((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] ) } async function handleSave() { setSaving(true) setError('') const patch: Partial = { hidden, featured, hiddenVariationIds: hiddenVars, hiddenModifierIds: hiddenMods, } // Always save categoriesOverride (replaces old single-field overrides) patch.categoriesOverride = selectedCatNames if (sortOrder !== '') patch.sortOrder = Number(sortOrder) if (showColors !== null) patch.showColors = showColors if (descOverride) patch.descriptionOverride = descOverride if (Object.keys(modifierMins).length) patch.modifierMinSelected = modifierMins if (colorMin !== '') patch.colorMin = Number(colorMin) if (colorMax !== '') patch.colorMax = Number(colorMax) if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100) patch.disabledColors = disabledColors.length ? disabledColors : undefined if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() else patch.quantityUnit = undefined patch.requiresDelivery = requiresDelivery || undefined patch.deliveryBaseOverride = deliveryBase !== '' ? Math.round(Number(deliveryBase) * 100) : null patch.deliveryPerMileOverride = deliveryPerMile !== '' ? Math.round(Number(deliveryPerMile) * 100) : null const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }) setSaving(false) if (res.ok) { setSaved(true) setTimeout(() => setSaved(false), 2000) onSaved(item.id, patch) } else { setError('Save failed') } } async function handleReset() { if (!confirm('Reset all overrides for this item to Square defaults?')) return const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'DELETE' }) if (res.ok) { setHidden(false) setFeatured(item.featured ?? false) setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel]) setSortOrder('') setShowColors(null) setHiddenMods([]) setDescOverride('') setModifierMins({}) setColorMin('') setColorMax('') setChromeSurcharge('') setRequiresDelivery(false) setDeliveryBase('') setDeliveryPerMile('') onSaved(item.id, {}) } } async function handleImageUpload(e: React.ChangeEvent) { const files = Array.from(e.target.files ?? []) if (!files.length) return setUploading(true) setUploadResults([]) const fd = new FormData() files.forEach((f) => fd.append('images', f)) const res = await fetch(`${BASE}/api/admin/items/${item.id}/images`, { method: 'POST', body: fd }) const data = await res.json() setUploadResults(data.results ?? []) setUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' } async function handleCreateCategory() { if (!newCatName.trim()) return setCreatingCat(true) const cat = await onCreateCategory(newCatName.trim()) // Auto-select the newly created category if (cat.id) setSelectedCatNames((prev) => [...prev, cat.name]) setNewCatName('') setShowNewCat(false) setCreatingCat(false) } return (
{/* Left column */}
{/* Visibility toggles */}
{requiresDelivery && (

Custom delivery rates for this item (leave blank to use global tier defaults)

setDeliveryBase(e.target.value)} style={{ width: 110 }} />
setDeliveryPerMile(e.target.value)} style={{ width: 110 }} />
)} {/* Category — multi-select checkboxes */}
{categories.map((c) => ( ))} {categories.length === 0 && (

No categories found — refresh from Square.

)}
{showNewCat && (
setNewCatName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()} />
)}
{/* Sort order */}
setSortOrder(e.target.value)} style={{ width: 100 }} />
{/* Show colors override */} {(item._rawShowColors || showColors) && (
)} {/* Description override */}