import { useState, useEffect, useCallback, useRef } from 'react' import { DEFAULT_COLOURS } from '../../../store/tableColourStore' import client from '../../../api/client' import toast from 'react-hot-toast' // ─── Colour slot metadata ──────────────────────────────────────────────────── const SLOTS = [ { key: 'cardBg', label: 'Primary Background', hint: 'Card background' }, { key: 'badgeBg', label: 'Secondary Background', hint: 'Status badge container' }, { key: 'nameText', label: 'Primary Text', hint: 'Table name' }, { key: 'badgeText', label: 'Secondary Text', hint: 'Badge label' }, ] const STATUSES = [ { key: 'free', label: 'Free Table' }, { key: 'open', label: 'Open Table (not mine)' }, { key: 'mine', label: 'Open Table (assigned to me)' }, { key: 'partially_paid', label: 'Partially Paid Table' }, { key: 'paid', label: 'Fully Paid Table' }, ] const STATUS_LABELS_MOCK = { free: 'ΕΛΕΥΘΕΡΟ', open: 'ΑΝΟΙΧΤΟ', mine: 'ΔΙΚΟ ΜΟΥ', partially_paid: 'ΜΕΡ. ΠΛHΡ.', paid: 'ΠΛΗΡΩΜΕΝΟ', } // Quick-suggest palettes per slot type const QUICK_SWATCHES = { cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'], badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'], nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'], badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'], } // ─── Color picker modal ────────────────────────────────────────────────────── // Parse any css colour string into { hex, alpha }. // Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b) function parseColour(v) { if (!v) return { hex: '#ffffff', alpha: 1 } const s = v.trim() // rgba / rgb const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/) if (rgbaMatch) { const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0') const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0') const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0') const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1 return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) } } // #rgb shorthand if (/^#[0-9a-fA-F]{3}$/.test(s)) { const [, r, g, b] = s return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 } } // #rrggbb if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 } return { hex: '#ffffff', alpha: 1 } } function buildColour(hex, alpha) { if (alpha >= 1) return hex const r = parseInt(hex.slice(1, 3), 16) const g = parseInt(hex.slice(3, 5), 16) const b = parseInt(hex.slice(5, 7), 16) return `rgba(${r},${g},${b},${alpha.toFixed(2)})` } function ColourPickerModal({ value, onClose, onChange, slot }) { const parsed = parseColour(value) const [hex, setHex] = useState(parsed.hex) const [alpha, setAlpha] = useState(parsed.alpha) // keep parent in sync whenever hex or alpha changes useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha]) function commitSwatch(v) { const p = parseColour(v) setHex(p.hex) setAlpha(p.alpha) } const preview = buildColour(hex, alpha) return (
e.stopPropagation()} >
Pick a Colour
{SLOTS.find(s => s.key === slot)?.label}
{/* Preview swatch — checkerboard behind so alpha is visible */}
0.5 ? '#fff' : '#374151', textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none', }}> {preview}
{/* Colour picker + hex input */}
Colour
setHex(e.target.value)} style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }} /> { const v = e.target.value setHex(v) }} spellCheck={false} style={{ flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827', }} />
{/* Opacity slider — always visible */}
Opacity
{Math.round(alpha * 100)}%
{/* Gradient track so you can see what you're dragging */}
setAlpha(parseFloat(e.target.value))} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', margin: 0, }} /> {/* thumb indicator */}
{/* Quick swatches */}
Quick select
{(QUICK_SWATCHES[slot] || []).map(c => { const p = parseColour(c) const built = buildColour(p.hex, p.alpha) return ( ) })}
) } // ─── Single colour slot row ────────────────────────────────────────────────── function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) { return (
) } // ─── Mini mock table card (for preview) ────────────────────────────────────── function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) { return (
{/* Table name + group */}
{mockName} {groupName}
{/* Status badge — tight equal padding on all sides */}
{label}
) } // ─── Preview panel (6 mock cards per theme) ────────────────────────────────── function PreviewPanel({ colours, mode }) { const isDark = mode === 'dark' const panelBg = isDark ? '#0d1520' : '#f1f5f9' const panelLabel = isDark ? '🌙 Dark Mode Preview' : '☀️ Light Mode Preview' const labelCol = isDark ? '#94a3b8' : '#64748b' const mockCards = [ { status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' }, { status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' }, { status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' }, { status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' }, { status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' }, { status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' }, ] return (
{panelLabel}
{mockCards.map((mc, i) => ( ))}
) } // ─── Status block (one status, showing all 4 slots) ────────────────────────── function StatusBlock({ mode, status, label, colours, onOpen }) { const cfg = colours[mode][status] return (
{label}
Click a swatch to edit
{SLOTS.map(slot => ( ))}
) } // ─── Mode section (light or dark) ──────────────────────────────────────────── function ModeSection({ mode, colours, onOpen }) { const label = mode === 'light' ? '☀️ Light Mode' : '🌙 Dark Mode' return (
{label}
{STATUSES.map(s => ( ))}
) } // ─── Main tab ──────────────────────────────────────────────────────────────── export default function ColoursTab() { const [colours, setColours] = useState(DEFAULT_COLOURS) const [modal, setModal] = useState(null) // { mode, status, slot, value } const [saving, setSaving] = useState(false) const saveTimer = useRef(null) // Load from backend on mount useEffect(() => { client.get('/api/settings/').then(r => { const raw = r.data?.['ui.table_colours']?.value if (raw) { try { setColours(JSON.parse(raw)) } catch {} } }) }, []) // Debounced save to backend — 600 ms after last change const saveToBackend = useCallback((next) => { clearTimeout(saveTimer.current) setSaving(true) saveTimer.current = setTimeout(() => { client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) }) .then(() => setSaving(false)) .catch(() => { toast.error('Failed to save colours'); setSaving(false) }) }, 600) }, []) function setColour(mode, status, slot, value) { setColours(prev => { const next = { ...prev, [mode]: { ...prev[mode], [status]: { ...prev[mode][status], [slot]: value }, }, } saveToBackend(next) return next }) } function openModal(mode, status, slot, value) { setModal({ mode, status, slot, value }) } function handleChange(value) { setColour(modal.mode, modal.status, modal.slot, value) setModal(m => ({ ...m, value })) } function handleReset() { if (window.confirm('Reset ALL colours to defaults? This cannot be undone.')) { setColours(DEFAULT_COLOURS) saveToBackend(DEFAULT_COLOURS) } } return (
{/* Section header */}

UI Personalization

Customise how the Waiter App looks. Changes are saved to the server and sync to all devices automatically. {saving && Saving…}

{/* Section: Waiter App — Table Colour Schemes */}
Waiter App — Table Colour Schemes

Each table card has four colour slots. Click any colour swatch below to open the colour picker.

{/* Live previews side by side */}
{/* Light + Dark mode settings */}
{/* Reset all button at bottom */}
{/* Colour picker modal */} {modal && ( setModal(null)} onChange={handleChange} /> )}
) }