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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 09:44:00 -04:00

1751 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string, string>()
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 (
<div>
<p style={{ fontWeight: 600, fontSize: '0.9rem', marginBottom: '0.5rem' }}>Tab order &amp; visibility</p>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.75rem' }}>
Controls which category tabs appear in the shop and in what order. Hidden categories still appear on individual items.
</p>
<table className="table is-narrow" style={{ fontSize: '0.85rem' }}>
<tbody>
{rows.map((row, i) => (
<tr key={row.key} style={{ opacity: row.hidden ? 0.4 : 1 }}>
<td style={{ verticalAlign: 'middle', paddingRight: 4 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<button
className="button is-white is-small"
style={{ padding: '0 6px', height: 18, minWidth: 0, lineHeight: 1 }}
disabled={i === 0}
onClick={() => move(i, -1)}
type="button"
title="Move up"
></button>
<button
className="button is-white is-small"
style={{ padding: '0 6px', height: 18, minWidth: 0, lineHeight: 1 }}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
type="button"
title="Move down"
></button>
</div>
</td>
<td style={{ verticalAlign: 'middle' }}>
<span className="tag is-medium is-light">{row.label}</span>
</td>
<td style={{ verticalAlign: 'middle' }}>
<label className="checkbox is-size-7">
<input
type="checkbox"
checked={!row.hidden}
onChange={() => toggleHidden(row.key)}
style={{ marginRight: 4 }}
/>
Show in tabs
</label>
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-light is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save tab order
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
</div>
)
}
// ─── Hours Editor ─────────────────────────────────────────────────────────────
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
function HoursEditor() {
const [config, setConfig] = useState<HoursConfig | null>(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 <p className="has-text-grey">Loading</p>
return (
<div>
{(['delivery', 'pickup'] as const).map((type) => (
<div key={type} style={{ marginBottom: '2rem' }}>
<h3 className="is-size-6" style={{ fontWeight: 700, marginBottom: '0.75rem', textTransform: 'capitalize' }}>
{type}
</h3>
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ width: 110 }}>Day</th>
<th style={{ width: 80 }}>Open</th>
<th style={{ width: 120 }}>Opens at</th>
<th style={{ width: 120 }}>Closes at</th>
</tr>
</thead>
<tbody>
{DAY_NAMES.map((dayName, dow) => {
const hours = config[type][String(dow)] ?? null
const isOpen = hours !== null
return (
<tr key={dow}>
<td style={{ verticalAlign: 'middle' }}>{dayName}</td>
<td style={{ verticalAlign: 'middle' }}>
<label className="checkbox">
<input
type="checkbox"
checked={isOpen}
onChange={(e) => {
if (e.target.checked) {
const def = DEFAULT_HOURS[type][String(dow)]
setDay(type, dow, def ?? { open: 600, close: 960 })
} else {
setDay(type, dow, null)
}
}}
style={{ marginRight: 4 }}
/>
Open
</label>
</td>
<td>
<input
type="time"
className="input is-small"
disabled={!isOpen}
value={isOpen ? minsToTime(hours!.open) : ''}
step={1800}
onChange={(e) => {
if (!isOpen) return
setDay(type, dow, { ...hours!, open: timeToMins(e.target.value) })
}}
style={{ width: 110 }}
/>
</td>
<td>
<input
type="time"
className="input is-small"
disabled={!isOpen}
value={isOpen ? minsToTime(hours!.close) : ''}
step={1800}
onChange={(e) => {
if (!isOpen) return
setDay(type, dow, { ...hours!, close: timeToMins(e.target.value) })
}}
style={{ width: 110 }}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save hours
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
<p className="help" style={{ marginTop: '0.5rem' }}>
Changes take effect immediately. Delivery slots already visible to customers are not affected until they reload.
</p>
</div>
)
}
// ─── 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<OccasionRow[]>([])
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<OccasionRow>) {
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<string, object> = {}
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<string, unknown> = {}
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 <p className="has-text-grey">Loading</p>
return (
<div>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '1rem' }}>
Control which holiday tabs appear in the shop, which Square category they filter, and the date window during which the tab is shown.
</p>
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ width: 160 }}>Holiday</th>
<th style={{ width: 60 }}>Show</th>
<th style={{ minWidth: 180 }}>Square category slug</th>
<th style={{ width: 145 }}>Window opens</th>
<th style={{ width: 145 }}>Window closes</th>
<th style={{ width: 36 }} />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key} style={{ opacity: row.enabled ? 1 : 0.45 }}>
<td style={{ verticalAlign: 'middle' }}>
{row.emoji} {row.label}
</td>
<td style={{ verticalAlign: 'middle' }}>
<label className="checkbox">
<input
type="checkbox"
checked={row.enabled}
onChange={(e) => update(row.key, { enabled: e.target.checked })}
style={{ marginRight: 4 }}
/>
On
</label>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
className="input is-small"
value={row.squareCategorySlug}
disabled={!row.enabled}
placeholder={row.defaultSlug || 'none'}
onChange={(e) => update(row.key, { squareCategorySlug: e.target.value })}
style={{ width: '100%', maxWidth: 210 }}
/>
{row.defaultSlug && row.squareCategorySlug !== row.defaultSlug && (
<div className="is-size-7 has-text-grey">default: {row.defaultSlug}</div>
)}
</td>
<td style={{ verticalAlign: 'middle' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="date"
className={`input is-small${row.windowStartOverridden ? '' : ' has-text-grey'}`}
disabled={!row.enabled}
value={row.windowStart ?? ''}
onChange={(e) => update(row.key, { windowStart: e.target.value, windowStartOverridden: true })}
style={{ width: 130 }}
/>
{!row.isCustom && row.windowStartOverridden && row.windowStart !== row.defaultWindowStart && (
<button
className="delete is-small"
title="Reset to computed"
onClick={() => update(row.key, { windowStart: row.defaultWindowStart, windowStartOverridden: false })}
/>
)}
</div>
{!row.isCustom && !row.windowStartOverridden && (
<div className="is-size-7 has-text-grey">computed</div>
)}
</td>
<td style={{ verticalAlign: 'middle' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="date"
className={`input is-small${row.windowEndOverridden ? '' : ' has-text-grey'}`}
disabled={!row.enabled}
value={row.windowEnd ?? ''}
onChange={(e) => update(row.key, { windowEnd: e.target.value, windowEndOverridden: true })}
style={{ width: 130 }}
/>
{!row.isCustom && row.windowEndOverridden && row.windowEnd !== row.defaultWindowEnd && (
<button
className="delete is-small"
title="Reset to computed"
onClick={() => update(row.key, { windowEnd: row.defaultWindowEnd, windowEndOverridden: false })}
/>
)}
</div>
{!row.isCustom && !row.windowEndOverridden && (
<div className="is-size-7 has-text-grey">computed</div>
)}
</td>
<td style={{ verticalAlign: 'middle', textAlign: 'center' }}>
{row.isCustom && (
<button
className="delete"
title="Remove holiday"
onClick={() => deleteRow(row.key)}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Add new custom holiday */}
{showForm ? (
<div style={{ border: '1px solid #ddd', borderRadius: 6, padding: '1rem', marginBottom: '1rem', background: '#fafafa' }}>
<p style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.9rem' }}>New holiday</p>
<div className="columns is-mobile is-multiline" style={{ gap: 0 }}>
<div className="column is-narrow">
<label className="label is-small">Emoji</label>
<input className="input is-small" value={newOcc.emoji} onChange={(e) => setNewOcc((p) => ({ ...p, emoji: e.target.value }))} style={{ width: 60 }} placeholder="🎉" />
</div>
<div className="column">
<label className="label is-small">Label</label>
<input className="input is-small" value={newOcc.label} onChange={(e) => setNewOcc((p) => ({ ...p, label: e.target.value }))} placeholder="Summer Sale" />
</div>
<div className="column is-full" style={{ paddingTop: 0 }}>
<label className="label is-small">Short description (shown in banner)</label>
<input className="input is-small" value={newOcc.blurb} onChange={(e) => setNewOcc((p) => ({ ...p, blurb: e.target.value }))} placeholder="Browse our summer collection." />
</div>
<div className="column">
<label className="label is-small">Square category slug</label>
<input className="input is-small" value={newOcc.squareCategorySlug} onChange={(e) => setNewOcc((p) => ({ ...p, squareCategorySlug: e.target.value }))} placeholder="summer-sale" />
</div>
<div className="column is-narrow">
<label className="label is-small">Window opens</label>
<input type="date" className="input is-small" value={newOcc.windowStart} onChange={(e) => setNewOcc((p) => ({ ...p, windowStart: e.target.value }))} style={{ width: 140 }} />
</div>
<div className="column is-narrow">
<label className="label is-small">Window closes</label>
<input type="date" className="input is-small" value={newOcc.windowEnd} onChange={(e) => setNewOcc((p) => ({ ...p, windowEnd: e.target.value }))} style={{ width: 140 }} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: '0.5rem' }}>
<button
className="button is-dark is-small"
type="button"
disabled={!newOcc.label || !newOcc.windowStart || !newOcc.windowEnd}
onClick={addCustom}
>
Add
</button>
<button className="button is-small" type="button" onClick={() => { setShowForm(false); setNewOcc(EMPTY_NEW) }}>
Cancel
</button>
</div>
</div>
) : (
<button
className="button is-small"
type="button"
style={{ marginBottom: '1rem' }}
onClick={() => setShowForm(true)}
>
+ Add holiday
</button>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save holidays
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
<p className="help" style={{ marginTop: '0.5rem' }}>
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.
</p>
</div>
)
}
// ─── 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(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 <p className="has-text-grey">Loading</p>
return (
<div>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '1rem' }}>
Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.
</p>
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ minWidth: 200 }}>Tier</th>
<th style={{ width: 130 }}>Base fee ($)</th>
<th style={{ width: 130 }}>Per mile ($)</th>
<th style={{ minWidth: 240 }}>Label</th>
</tr>
</thead>
<tbody>
{(['dropoff', 'classic', 'organic'] as const).map((tier) => (
<tr key={tier}>
<td style={{ verticalAlign: 'middle', fontWeight: 500 }}>{TIER_LABELS[tier]}</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].base / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'base', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].perMile / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'perMile', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="text"
className="input is-small"
value={rates[tier].label}
onChange={(e) => updateTier(tier, 'label', e.target.value)}
style={{ width: '100%' }}
/>
</td>
</tr>
))}
</tbody>
</table>
<p className="help" style={{ marginBottom: '0.75rem' }}>
Formula: <strong>base + ceil(miles) × per-mile</strong>. Example: drop-off to a 5-mile address =
{' '}base + 5 × per-mile.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save rates
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
</div>
)
}
// ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({
item,
categories,
onSaved,
onCreateCategory,
}: {
item: AdminItem
categories: SquareCategory[]
onSaved: (id: string, ov: Partial<ItemOverride>) => void
onCreateCategory: (name: string) => Promise<SquareCategory>
}) {
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<string[]>(
ov.categoriesOverride ?? item.categoryLabels ?? [item.categoryLabel]
)
const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
const [showColors, setShowColors] = useState<boolean | null>(
ov.showColors != null ? ov.showColors : null
)
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
// Per-modifier min overrides
const [modifierMins, setModifierMins] = useState<Record<string, number>>(
ov.modifierMinSelected ?? {}
)
// Image upload
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<{ name: string; url?: string | null; error?: string }[]>([])
// Color limits
const [colorMin, setColorMin] = useState<string>(String(ov.colorMin ?? ''))
const [colorMax, setColorMax] = useState<string>(ov.colorMax != null ? String(ov.colorMax) : '')
const [chromeSurcharge, setChromeSurcharge] = useState<string>(
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : ''
)
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
const [showColorFilter, setShowColorFilter] = useState(false)
const [quantityUnit, setQuantityUnit] = useState<string>(ov.quantityUnit ?? '')
const [requiresDelivery, setRequiresDelivery] = useState(ov.requiresDelivery ?? false)
const [deliveryBase, setDeliveryBase] = useState<string>(
ov.deliveryBaseOverride != null ? String(ov.deliveryBaseOverride / 100) : ''
)
const [deliveryPerMile, setDeliveryPerMile] = useState<string>(
ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : ''
)
// Create category
const [newCatName, setNewCatName] = useState('')
const [creatingCat, setCreatingCat] = useState(false)
const [showNewCat, setShowNewCat] = useState(false)
function toggleVar(id: string) {
setHiddenVars((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
function toggleMod(id: string) {
setHiddenMods((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
async function handleSave() {
setSaving(true)
setError('')
const patch: Partial<ItemOverride> = {
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<HTMLInputElement>) {
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 (
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
<div className="columns is-multiline">
{/* Left column */}
<div className="column is-half">
{/* Visibility toggles */}
<div className="field" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
<label className="checkbox" style={{ fontWeight: 600 }}>
<input
type="checkbox"
checked={hidden}
onChange={(e) => setHidden(e.target.checked)}
style={{ marginRight: 6 }}
/>
Hidden from storefront
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#11b3be' }}>
<input
type="checkbox"
checked={featured}
onChange={(e) => setFeatured(e.target.checked)}
style={{ marginRight: 6, accentColor: '#11b3be' }}
/>
Featured
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#c0392b' }}>
<input
type="checkbox"
checked={requiresDelivery}
onChange={(e) => setRequiresDelivery(e.target.checked)}
style={{ marginRight: 6, accentColor: '#c0392b' }}
/>
🚗 Requires delivery
</label>
</div>
{requiresDelivery && (
<div className="field" style={{ background: '#fff8f8', border: '1px solid #f5c6c6', borderRadius: 6, padding: '0.6rem 0.8rem', marginBottom: '0.75rem' }}>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.4rem' }}>
Custom delivery rates for this item (leave blank to use global tier defaults)
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<label className="label is-small" style={{ marginBottom: 2 }}>Base charge ($)</label>
<input
className="input is-small"
type="number"
min="0"
step="0.01"
placeholder="e.g. 75.00"
value={deliveryBase}
onChange={(e) => setDeliveryBase(e.target.value)}
style={{ width: 110 }}
/>
</div>
<div>
<label className="label is-small" style={{ marginBottom: 2 }}>Per mile ($)</label>
<input
className="input is-small"
type="number"
min="0"
step="0.01"
placeholder="e.g. 4.00"
value={deliveryPerMile}
onChange={(e) => setDeliveryPerMile(e.target.value)}
style={{ width: 110 }}
/>
</div>
</div>
</div>
)}
{/* Category — multi-select checkboxes */}
<div className="field">
<label className="label is-small">Categories <span className="has-text-grey-light" style={{ fontWeight: 'normal' }}>(item appears in all checked tabs)</span></label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto', border: '1px solid #e8e8e8', borderRadius: 6, padding: '6px 8px' }}>
{categories.map((c) => (
<label key={c.id} className="checkbox" style={{ fontSize: '0.85rem' }}>
<input
type="checkbox"
checked={selectedCatNames.includes(c.name)}
onChange={(e) => {
setSelectedCatNames((prev) =>
e.target.checked ? [...prev, c.name] : prev.filter((n) => n !== c.name)
)
}}
style={{ marginRight: 6 }}
/>
{c.name}
{(item.categoryLabels ?? [item.categoryLabel]).includes(c.name) && (
<span className="has-text-grey-light" style={{ fontSize: '0.72rem', marginLeft: 6 }}>Square</span>
)}
</label>
))}
{categories.length === 0 && (
<p className="is-size-7 has-text-grey">No categories found refresh from Square.</p>
)}
</div>
<button
className="button is-ghost is-small"
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
onClick={() => setShowNewCat(!showNewCat)}
type="button"
>
+ Create new category in Square
</button>
{showNewCat && (
<div className="field has-addons" style={{ marginTop: 6 }}>
<div className="control is-expanded">
<input
className="input is-small"
placeholder="New category name"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()}
/>
</div>
<div className="control">
<button
className={`button is-small is-dark${creatingCat ? ' is-loading' : ''}`}
onClick={handleCreateCategory}
type="button"
>
Create
</button>
</div>
</div>
)}
</div>
{/* Sort order */}
<div className="field">
<label className="label is-small">Sort order <span className="has-text-grey-light">(lower = first, 0 = unsorted)</span></label>
<div className="control">
<input
className="input is-small"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
style={{ width: 100 }}
/>
</div>
</div>
{/* Show colors override */}
{(item._rawShowColors || showColors) && (
<div className="field">
<label className="label is-small">Latex color picker</label>
<div className="control">
<label className="radio" style={{ marginRight: 12 }}>
<input
type="radio"
name={`colors-${item.id}`}
checked={showColors === null}
onChange={() => setShowColors(null)}
/>{' '}
Square default ({item._rawShowColors ? 'on' : 'off'})
</label>
<label className="radio" style={{ marginRight: 12 }}>
<input
type="radio"
name={`colors-${item.id}`}
checked={showColors === true}
onChange={() => setShowColors(true)}
/>{' '}
Always on
</label>
<label className="radio">
<input
type="radio"
name={`colors-${item.id}`}
checked={showColors === false}
onChange={() => setShowColors(false)}
/>{' '}
Always off
</label>
</div>
</div>
)}
{/* Description override */}
<div className="field">
<label className="label is-small">Description override <span className="has-text-grey-light">(leave blank to use Square)</span></label>
<div className="control">
<textarea
className="textarea is-small"
rows={3}
placeholder={item._rawDescription || 'Square description'}
value={descOverride}
onChange={(e) => setDescOverride(e.target.value)}
/>
</div>
</div>
</div>
{/* Right column */}
<div className="column is-half">
{/* Variations */}
{item._rawVariations.length > 1 && (
<div className="field">
<label className="label is-small">Variations</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{item._rawVariations.map((v) => {
const visible = !hiddenVars.includes(v.id)
return (
<div key={v.id} style={{
border: '1px solid #e8e8e8',
borderRadius: 6,
padding: '8px 10px',
background: visible ? '#fff' : '#fafafa',
opacity: visible ? 1 : 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="checkbox" style={{ fontWeight: 500, fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={visible}
onChange={() => toggleVar(v.id)}
style={{ marginRight: 6 }}
/>
{v.name}
</label>
<span className="has-text-grey-light" style={{ fontSize: '0.75rem' }}>
${(v.priceCents / 100).toFixed(2)}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Modifiers */}
{item._rawModifiers.length > 0 && (
<div className="field">
<label className="label is-small">Modifiers</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{item._rawModifiers.map((m) => {
const visible = !hiddenMods.includes(m.id)
const squareMin = m.minSelected ?? 0
const currentMin = modifierMins[m.id] ?? squareMin
const isRequired = currentMin > 0
return (
<div key={m.id} style={{
border: '1px solid #e8e8e8',
borderRadius: 6,
padding: '8px 10px',
background: visible ? '#fff' : '#fafafa',
opacity: visible ? 1 : 0.5,
}}>
{/* Show / hide toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label className="checkbox" style={{ fontWeight: 500, fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={visible}
onChange={() => toggleMod(m.id)}
style={{ marginRight: 6 }}
/>
{m.name}
</label>
<span className="has-text-grey-light" style={{ fontSize: '0.75rem' }}>
{m.options.length} options · {m.selectionType.toLowerCase()}
</span>
{/* Required toggle — only when modifier is visible */}
{visible && (
<label className="checkbox" style={{ fontSize: '0.8rem', marginLeft: 'auto' }}>
<input
type="checkbox"
checked={isRequired}
onChange={(e) => {
setModifierMins((prev) => {
const next = { ...prev }
if (e.target.checked) {
next[m.id] = Math.max(1, squareMin)
} else {
next[m.id] = 0
}
return next
})
}}
style={{ marginRight: 4 }}
/>
Required
</label>
)}
{/* Min count — when required */}
{visible && isRequired && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.8rem' }}>
<span className="has-text-grey">min:</span>
<input
type="number"
min={1}
max={m.options.length}
value={currentMin}
onChange={(e) => setModifierMins((prev) => ({
...prev,
[m.id]: Math.max(1, Number(e.target.value)),
}))}
style={{ width: 48, padding: '1px 4px', border: '1px solid #ccc', borderRadius: 4, fontSize: '0.8rem' }}
/>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Color min / max */}
{(item.showColors || item._rawShowColors) && (
<div className="field" style={{ marginTop: '1rem' }}>
<label className="label is-small">Color selection limits</label>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: '0.82rem', color: '#555' }}>Min colors required</label>
<input
type="number"
min={1}
value={colorMin}
placeholder={String(item.colorMin ?? 1)}
onChange={(e) => setColorMin(e.target.value)}
style={{ width: 60, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, fontSize: '0.85rem' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: '0.82rem', color: '#555' }}>Max colors allowed</label>
<input
type="number"
min={1}
value={colorMax}
placeholder={item.colorMax != null ? String(item.colorMax) : 'unlimited'}
onChange={(e) => setColorMax(e.target.value)}
style={{ width: 80, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, fontSize: '0.85rem' }}
/>
<button
type="button"
className="button is-ghost is-small"
style={{ fontSize: '0.75rem', padding: '0 4px' }}
onClick={() => setColorMax('')}
title="Set to unlimited"
>
unlimited
</button>
</div>
</div>
<p className="help">Leave max blank for unlimited. Current: min {item.colorMin ?? 1}, max {item.colorMax ?? 'unlimited'}.</p>
{/* Chrome surcharge */}
<div style={{ marginTop: '0.75rem', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ fontSize: '0.82rem', color: '#555' }}>Chrome surcharge per color ($)</label>
<input
type="number"
min={0}
step={0.01}
value={chromeSurcharge}
placeholder="0.00"
onChange={(e) => setChromeSurcharge(e.target.value)}
style={{ width: 80, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, fontSize: '0.85rem' }}
/>
<button
type="button"
className="button is-ghost is-small"
style={{ fontSize: '0.75rem', padding: '0 4px' }}
onClick={() => setChromeSurcharge('')}
>
clear
</button>
</div>
<p className="help">
Each chrome color selected adds this amount. Leave blank (or 0) to use a flat "Chrome" Square variation instead.
{item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`}
</p>
{/* Color availability */}
<div style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="button is-small is-light"
onClick={() => setShowColorFilter(true)}
>
🎨 Manage available colors
{disabledColors.length > 0 && (
<span style={{
marginLeft: 6, background: '#c07000', color: '#fff',
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
padding: '1px 7px',
}}>
{disabledColors.length} hidden
</span>
)}
</button>
</div>
</div>
)}
{showColorFilter && (
<AdminColorFilter
disabledColors={disabledColors}
onSave={setDisabledColors}
onClose={() => setShowColorFilter(false)}
/>
)}
{/* Quantity unit */}
<div className="field" style={{ marginTop: '1rem' }}>
<label className="label is-small">Quantity unit</label>
<div className="field has-addons" style={{ maxWidth: 260 }}>
<div className="control is-expanded">
<input
className="input is-small"
placeholder='e.g. ft — leave blank for plain count'
value={quantityUnit}
onChange={(e) => setQuantityUnit(e.target.value)}
/>
</div>
{quantityUnit && (
<div className="control">
<button className="button is-small" type="button" onClick={() => setQuantityUnit('')}>clear</button>
</div>
)}
</div>
<p className="help">When set, the quantity control shows "X ft" and labels itself "Length (ft)".</p>
</div>
{/* Images */}
<div className="field" style={{ marginTop: item._rawModifiers.length ? '1rem' : 0 }}>
<label className="label is-small">Images</label>
{/* Current images */}
{item.imageUrls.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
{item.imageUrls.map((url, i) => (
<img
key={i}
src={url}
alt=""
style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6, border: '1px solid #ddd' }}
/>
))}
</div>
)}
{/* Upload */}
<div className="file is-small">
<label className="file-label">
<input
ref={fileInputRef}
className="file-input"
type="file"
multiple
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif,image/bmp,.heic,.heif"
onChange={handleImageUpload}
/>
<span className="file-cta">
<span className="file-icon"><i className="fas fa-upload" /></span>
<span className="file-label">{uploading ? 'Uploading…' : 'Upload images'}</span>
</span>
</label>
</div>
<p className="help">JPEG, PNG, WEBP, GIF supported. HEIC/HEIF must be converted first.</p>
{uploadResults.length > 0 && (
<div style={{ marginTop: 8 }}>
{uploadResults.map((r, i) => (
<p key={i} className={`help ${r.error ? 'is-danger' : 'is-success'}`}>
{r.name}: {r.error ?? '✓ uploaded'}
</p>
))}
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: '0.5rem' }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
{saved ? '✓ Saved' : 'Save changes'}
</button>
<button
className="button is-small is-ghost has-text-danger"
onClick={handleReset}
type="button"
>
Reset to defaults
</button>
{error && <span className="has-text-danger is-size-7" style={{ alignSelf: 'center' }}>{error}</span>}
</div>
</div>
)
}
// ─── Item Row ─────────────────────────────────────────────────────────────────
function ItemRow({
item,
categories,
onSaved,
onCreateCategory,
}: {
item: AdminItem
categories: SquareCategory[]
onSaved: (id: string, ov: Partial<ItemOverride>) => void
onCreateCategory: (name: string) => Promise<SquareCategory>
}) {
const [open, setOpen] = useState(false)
const hasOverride = Object.keys(item._override).length > 0
return (
<div style={{
border: '1px solid #e0e0e0',
borderRadius: 8,
marginBottom: 8,
overflow: 'hidden',
opacity: item.hidden ? 0.5 : 1,
}}>
{/* Header row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '0.65rem 1rem',
cursor: 'pointer',
backgroundColor: open ? '#f5f5f5' : '#fff',
}}
onClick={() => setOpen(!open)}
>
{/* Thumbnail */}
<div style={{
width: 44,
height: 44,
borderRadius: 6,
overflow: 'hidden',
flexShrink: 0,
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{item.imageUrl
? <img src={item.imageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <i className="fas fa-image" style={{ color: '#ccc' }} />
}
</div>
{/* Name + badges */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name}
{item.hidden && (
<span className="tag is-warning is-small" style={{ marginLeft: 8 }}>hidden</span>
)}
{hasOverride && !item.hidden && (
<span className="tag is-info is-light is-small" style={{ marginLeft: 8 }}>overridden</span>
)}
</div>
<div style={{ fontSize: '0.78rem', color: '#777' }}>
{item.categoryLabel} · {cents(item.price)}
{item.sortOrder > 0 && ` · sort: ${item.sortOrder}`}
</div>
</div>
{/* Modifier count */}
<div style={{ fontSize: '0.75rem', color: '#999', flexShrink: 0 }}>
{item._rawModifiers.length > 0
? `${item.modifiers.length}/${item._rawModifiers.length} mods`
: 'no mods'
}
</div>
{/* Chevron */}
<i className={`fas fa-chevron-${open ? 'up' : 'down'}`} style={{ color: '#aaa', fontSize: '0.75rem' }} />
</div>
{open && (
<ItemEditor
item={item}
categories={categories}
onSaved={onSaved}
onCreateCategory={onCreateCategory}
/>
)}
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AdminPage() {
const router = useRouter()
const [items, setItems] = useState<AdminItem[]>([])
const [categories, setCategories] = useState<SquareCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
// Category creation state (for the Categories tab)
const [newCatName, setNewCatName] = useState('')
const [creatingCat, setCreatingCat] = useState(false)
const [catMessage, setCatMessage] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
const [itemsRes, catsRes] = await Promise.all([
fetch(BASE + '/api/admin/items'),
fetch(BASE + '/api/admin/categories'),
])
if (itemsRes.status === 401) {
router.push('/admin/login')
return
}
if (!itemsRes.ok) {
setError('Failed to load catalog')
setLoading(false)
return
}
const data = await itemsRes.json()
const { categories } = catsRes.ok ? await catsRes.json() : { categories: [] }
setItems(data.items)
if (data.fetchedAt) setFetchedAt(data.fetchedAt)
setCategories(categories)
setLoading(false)
}, [router])
async function handleRefreshCatalog() {
setRefreshing(true)
setRefreshMsg('')
const res = await fetch(BASE + '/api/admin/cache/refresh', { method: 'POST' })
const data = await res.json()
setRefreshing(false)
if (res.ok) {
setRefreshMsg(`Refreshed — ${data.itemCount} items`)
setFetchedAt(data.fetchedAt)
// Reload admin item list to pick up any new items
load()
} else {
setRefreshMsg(`Error: ${data.error}`)
}
}
useEffect(() => { load() }, [load])
async function handleLogout() {
await fetch(BASE + '/api/admin/logout', { method: 'POST' })
router.push('/admin/login')
}
async function handleCreateCategory(name: string): Promise<SquareCategory> {
const res = await fetch(BASE + '/api/admin/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (data.category) {
setCategories((prev) => [...prev, data.category].sort((a, b) => a.name.localeCompare(b.name)))
}
return data.category ?? { id: '', name }
}
async function handleCreateCategoryTab() {
if (!newCatName.trim()) return
setCreatingCat(true)
setCatMessage('')
const cat = await handleCreateCategory(newCatName.trim())
setNewCatName('')
setCreatingCat(false)
setCatMessage(cat.id ? `Created: ${cat.name}` : 'Failed to create category')
}
function handleSaved(id: string, patch: Partial<ItemOverride>) {
setItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, _override: { ...item._override, ...patch } }
: item
)
)
}
const q = search.trim().toLowerCase()
const filtered = items.filter((item) =>
!q ||
item.name.toLowerCase().includes(q) ||
item.categoryLabel.toLowerCase().includes(q)
)
return (
<section className="section">
<div className="container" style={{ maxWidth: 900 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1.5rem', gap: 12, flexWrap: 'wrap' }}>
<div style={{ flex: 1 }}>
<h1 className="title is-4" style={{ marginBottom: 0 }}>Admin</h1>
<p className="has-text-grey is-size-7">Beach Party Balloons catalog management</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
className={`button is-small is-info is-light${refreshing ? ' is-loading' : ''}`}
onClick={handleRefreshCatalog}
title="Re-fetch catalog from Square"
>
<span className="icon is-small"><i className="fas fa-cloud-download-alt" /></span>
<span>Refresh from Square</span>
</button>
<button className="button is-small" onClick={load} title="Reload page data">
<i className="fas fa-sync" />
</button>
<button className="button is-small" onClick={handleLogout}>Sign out</button>
</div>
{refreshMsg && (
<p className={`is-size-7 ${refreshMsg.startsWith('Error') ? 'has-text-danger' : 'has-text-success'}`}>
{refreshMsg}
</p>
)}
{fetchedAt && !refreshMsg && (
<p className="is-size-7 has-text-grey">
Cache: {new Date(fetchedAt).toLocaleString()}
</p>
)}
</div>
</div>
{/* Tabs */}
<div className="tabs" style={{ marginBottom: '1rem' }}>
<ul>
<li className={tab === 'items' ? 'is-active' : ''}>
<a onClick={() => setTab('items')}>Items ({items.length})</a>
</li>
<li className={tab === 'categories' ? 'is-active' : ''}>
<a onClick={() => setTab('categories')}>Categories</a>
</li>
<li className={tab === 'hours' ? 'is-active' : ''}>
<a onClick={() => setTab('hours')}>Hours</a>
</li>
<li className={tab === 'occasions' ? 'is-active' : ''}>
<a onClick={() => setTab('occasions')}>Holidays</a>
</li>
<li className={tab === 'delivery' ? 'is-active' : ''}>
<a onClick={() => setTab('delivery')}>Delivery rates</a>
</li>
</ul>
</div>
{/* Items tab */}
{tab === 'items' && (
<>
{/* Search */}
<div className="field" style={{ marginBottom: '1rem' }}>
<div className="control has-icons-left">
<input
className="input is-small"
type="search"
placeholder="Filter items…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span className="icon is-small is-left">
<i className="fas fa-search" />
</span>
</div>
</div>
{loading ? (
<p className="has-text-grey">Loading catalog</p>
) : error ? (
<p className="has-text-danger">{error}</p>
) : filtered.length === 0 ? (
<p className="has-text-grey">No items found.</p>
) : (
<div>
<p className="has-text-grey is-size-7" style={{ marginBottom: '0.5rem' }}>
{filtered.length} item{filtered.length !== 1 ? 's' : ''} · Click an item to edit
</p>
{filtered.map((item) => (
<ItemRow
key={item.id}
item={item}
categories={categories}
onSaved={handleSaved}
onCreateCategory={handleCreateCategory}
/>
))}
</div>
)}
</>
)}
{/* Categories tab */}
{tab === 'categories' && (
<div>
<p className="has-text-grey is-size-7" style={{ marginBottom: '1rem' }}>
These are your Square catalog categories. Creating one here adds it to Square immediately.
</p>
{/* Create new */}
<div className="field has-addons" style={{ maxWidth: 400, marginBottom: '1.5rem' }}>
<div className="control is-expanded">
<input
className="input is-small"
placeholder="New category name"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateCategoryTab()}
/>
</div>
<div className="control">
<button
className={`button is-light is-small${creatingCat ? ' is-loading' : ''}`}
onClick={handleCreateCategoryTab}
type="button"
>
Create
</button>
</div>
</div>
{catMessage && (
<p className={`help ${catMessage.startsWith('Failed') ? 'is-danger' : 'is-success'}`} style={{ marginTop: -12, marginBottom: 12 }}>
{catMessage}
</p>
)}
{/* List */}
{loading ? (
<p className="has-text-grey">Loading</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: '2rem' }}>
{categories.map((c) => (
<span key={c.id} className="tag is-medium is-light">{c.name}</span>
))}
</div>
)}
{/* Tab display order */}
{!loading && <CategoryDisplayEditor items={items} />}
</div>
)}
{/* Hours tab */}
{tab === 'hours' && <HoursEditor />}
{/* Holidays tab */}
{tab === 'occasions' && <OccasionsEditor />}
{/* Delivery rates tab */}
{tab === 'delivery' && <DeliveryRatesEditor />}
</div>
</section>
)
}