- 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>
1751 lines
69 KiB
TypeScript
1751 lines
69 KiB
TypeScript
'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 & 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>
|
||
)
|
||
}
|