Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:12:23 +03:00
parent defc49f84f
commit bb39088464
78 changed files with 24370 additions and 1358 deletions

16
waiter_pwa/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

92
waiter_pwa/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.7tvu7c24jlg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>waiter_pwa</title>
</head>
<body>

View File

@@ -1,20 +1,260 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
import useShiftStore from './store/shiftStore'
import useThemeStore from './store/themeStore'
import useTableColourStore from './store/tableColourStore'
import client from './api/client'
import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage'
import AddItemsPage from './pages/AddItemsPage'
import OfflinePage from './pages/OfflinePage'
import { NotificationProvider } from './context/NotificationContext'
function ProtectedRoute({ children }) {
const token = useAuthStore(s => s.token)
if (!token) return <Navigate to="/login" replace />
return children
// ─── Utility ─────────────────────────────────────────────────────────────────
function Spinner() {
return (
<div className="page page--centered" style={{ gap: 12 }}>
<div style={{
width: 36, height: 36,
border: '3px solid var(--border)',
borderTopColor: 'var(--accent)',
borderRadius: '50%',
animation: 'gate-spin 0.7s linear infinite',
}} />
<span style={{ color: 'var(--muted)', fontSize: 14 }}>Φόρτωση</span>
</div>
)
}
// Rehydrates user object from token on every app load
// ─── Gate Screens ─────────────────────────────────────────────────────────────
function GateCard({ emoji, title, subtitle, children }) {
return (
<div className="page page--centered" style={{ gap: 24, padding: 32 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 52, marginBottom: 12 }}>{emoji}</div>
<p style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 6 }}>{title}</p>
{subtitle && <p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.5 }}>{subtitle}</p>}
</div>
{children}
</div>
)
}
function GateBtn({ onClick, disabled, variant = 'primary', children }) {
const base = {
height: 44, padding: '0 24px', borderRadius: 12, border: 'none',
fontSize: 15, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1, transition: 'opacity 120ms',
}
const styles = {
primary: { background: 'var(--accent)', color: '#0f172a' },
secondary: { background: 'var(--bg3)', color: 'var(--text)' },
danger: { background: 'var(--danger)', color: '#fff' },
}
return <button style={{ ...base, ...styles[variant] }} onClick={onClick} disabled={disabled}>{children}</button>
}
function ClosedScreen({ onRefresh, onLogout }) {
return (
<GateCard emoji="🔒" title="Εστιατόριο κλειστό"
subtitle={'Δεν υπάρχει ενεργή ημέρα λειτουργίας.\nΖητήστε από τον διαχειριστή να ανοίξει την ημέρα.'}>
<div style={{ display: 'flex', gap: 12 }}>
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
</div>
</GateCard>
)
}
function WaitingManagerScreen({ onRefresh, onLogout }) {
return (
<GateCard emoji="⏳" title="Αναμονή για έναρξη βάρδιας"
subtitle="Ζητήστε από τον διαχειριστή να ξεκινήσει τη βάρδια σας.">
<div style={{ display: 'flex', gap: 12 }}>
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
</div>
</GateCard>
)
}
function StartShiftScreen({ username, onStart, onLogout }) {
const [startingCash, setStartingCash] = useState('')
const [starting, setStarting] = useState(false)
const [error, setError] = useState(null)
async function handleStart() {
setStarting(true)
setError(null)
try {
await onStart(startingCash ? parseFloat(startingCash) : null)
} catch (e) {
setError(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
setStarting(false)
}
}
return (
<GateCard emoji="👋" title={`Καλώς ήρθες, ${username}!`}
subtitle="Θέλεις να ξεκινήσεις τη βάρδια σου;">
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 16, padding: 20,
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--muted)', display: 'block', marginBottom: 6 }}>
Αρχικά μετρητά (προαιρετικό)
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--muted)', fontWeight: 700 }}></span>
<input
type="number" step="0.01" min="0" placeholder="0.00"
value={startingCash}
onChange={e => setStartingCash(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleStart()}
style={{
flex: 1, background: 'var(--bg3)', border: '1px solid var(--border)',
borderRadius: 10, padding: '10px 12px',
color: 'var(--text)', fontSize: 16, outline: 'none',
}}
/>
</div>
</div>
{error && (
<p style={{
fontSize: 13, color: 'var(--danger)',
background: 'var(--danger-dim)', borderRadius: 8, padding: '8px 12px',
}}>{error}</p>
)}
<GateBtn onClick={handleStart} disabled={starting}>
{starting ? 'Εκκίνηση…' : '▶ Έναρξη Βάρδιας'}
</GateBtn>
</div>
<button
style={{ fontSize: 13, color: 'var(--muted)', background: 'none', border: 'none', cursor: 'pointer' }}
onClick={onLogout}
>
Αποσύνδεση
</button>
</GateCard>
)
}
// ─── Protected Layout with Shift Gate ────────────────────────────────────────
function AppLayout() {
const { token, user, logout } = useAuthStore()
const navigate = useNavigate()
const {
shift, businessDay,
setShift, setBusinessDay,
setSelfStartAllowed, setSelfEndAllowed,
gateStatus, setGateStatus,
} = useShiftStore()
if (!token) return <Navigate to="/login" replace />
const isManager = user?.role && user.role !== 'waiter'
async function checkGate() {
if (!user) return
if (isManager) { setGateStatus('ready'); return }
setGateStatus('loading')
try {
const dayRes = await client.get('/api/business-day/current')
const day = dayRes.data
setBusinessDay(day)
if (!day) { setGateStatus('closed'); return }
const shiftRes = await client.get('/api/shifts/my')
if (shiftRes.data) {
setShift(shiftRes.data)
setGateStatus('ready')
return
}
// No active shift — check self-start setting
try {
const settingsRes = await client.get('/api/settings/')
const canStart = settingsRes.data?.['shifts.waiter_self_start']?.value !== 'false'
const canEnd = settingsRes.data?.['shifts.waiter_self_end']?.value !== 'false'
setSelfStartAllowed(canStart)
setSelfEndAllowed(canEnd)
setGateStatus(canStart ? 'needs_start' : 'waiting_manager')
} catch {
setSelfStartAllowed(true)
setSelfEndAllowed(true)
setGateStatus('needs_start')
}
} catch {
setBusinessDay(null)
setGateStatus('closed')
}
}
useEffect(() => {
if (user) checkGate()
}, [user?.id])
// Poll every 15s to detect shift-end or business-day-close triggered by manager
useEffect(() => {
if (!user || isManager || gateStatus !== 'ready') return
const id = setInterval(async () => {
try {
const dayRes = await client.get('/api/business-day/current')
if (!dayRes.data) { setGateStatus('closed'); return }
const shiftRes = await client.get('/api/shifts/my')
if (!shiftRes.data) {
// Shift was ended by manager — rerun full gate check
checkGate()
}
} catch {
// network error — ignore, don't lock
}
}, 15_000)
return () => clearInterval(id)
}, [user, isManager, gateStatus])
async function handleStartShift(startingCash) {
const res = await client.post('/api/shifts/start', { starting_cash: startingCash })
setShift(res.data)
setGateStatus('ready')
}
function handleLogout() {
logout()
navigate('/login')
}
if (!user || gateStatus === 'loading') return <Spinner />
if (gateStatus === 'closed') return <ClosedScreen onRefresh={checkGate} onLogout={handleLogout} />
if (gateStatus === 'waiting_manager') return <WaitingManagerScreen onRefresh={checkGate} onLogout={handleLogout} />
if (gateStatus === 'needs_start') {
return (
<StartShiftScreen
username={user.username}
onStart={handleStartShift}
onLogout={handleLogout}
/>
)
}
return <Outlet />
}
// ─── Global helpers ───────────────────────────────────────────────────────────
function AuthRehydrator() {
const { token, user, login, logout } = useAuthStore()
useEffect(() => {
@@ -37,19 +277,48 @@ function OfflineListener() {
return null
}
function ThemeApplier() {
const dark = useThemeStore(s => s.dark)
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
}, [dark])
return null
}
function ColourLoader() {
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
useEffect(() => {
client.get('/api/settings/')
.then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) loadFromBackend(raw)
})
.catch(() => {})
}, [])
return null
}
// ─── App ──────────────────────────────────────────────────────────────────────
export default function App() {
return (
<BrowserRouter>
<ThemeApplier />
<ColourLoader />
<AuthRehydrator />
<OfflineListener />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/offline" element={<OfflinePage />} />
<Route path="/tables" element={<ProtectedRoute><TableListPage /></ProtectedRoute>} />
<Route path="/tables/:tableId" element={<ProtectedRoute><TableDetailPage /></ProtectedRoute>} />
<Route path="/tables/:tableId/add" element={<ProtectedRoute><AddItemsPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/tables" replace />} />
</Routes>
<NotificationProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/offline" element={<OfflinePage />} />
<Route element={<AppLayout />}>
<Route path="/tables" element={<TableListPage />} />
<Route path="/tables/:tableId" element={<TableDetailPage />} />
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
</Route>
<Route path="*" element={<Navigate to="/tables" replace />} />
</Routes>
</NotificationProvider>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="#1C274C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.24 2H5.34C3.15 2 2 3.15 2 5.33V7.23C2 9.41 3.15 10.56 5.33 10.56H7.23C9.41 10.56 10.56 9.41 10.56 7.23V5.33C10.57 3.15 9.42 2 7.24 2Z" fill="currentColor"/>
<path opacity="0.4" d="M18.6695 2H16.7695C14.5895 2 13.4395 3.15 13.4395 5.33V7.23C13.4395 9.41 14.5895 10.56 16.7695 10.56H18.6695C20.8495 10.56 21.9995 9.41 21.9995 7.23V5.33C21.9995 3.15 20.8495 2 18.6695 2Z" fill="currentColor"/>
<path d="M18.6695 13.4302H16.7695C14.5895 13.4302 13.4395 14.5802 13.4395 16.7602V18.6602C13.4395 20.8402 14.5895 21.9902 16.7695 21.9902H18.6695C20.8495 21.9902 21.9995 20.8402 21.9995 18.6602V16.7602C21.9995 14.5802 20.8495 13.4302 18.6695 13.4302Z" fill="currentColor"/>
<path opacity="0.4" d="M7.24 13.4302H5.34C3.15 13.4302 2 14.5802 2 16.7602V18.6602C2 20.8502 3.15 22.0002 5.33 22.0002H7.23C9.41 22.0002 10.56 20.8502 10.56 18.6702V16.7702C10.57 14.5802 9.42 13.4302 7.24 13.4302Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="233px" height="233px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 747 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="currentColor" width="800px" height="800px" viewBox="-4 -2 24 24" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin" class="jam jam-merge-f"><path d='M9.033 5.817v2.028c0 .074-.003.148-.008.221a1 1 0 0 0 .462.637l3.086 1.846a3 3 0 0 1 1.46 2.575v1.059a3.001 3.001 0 1 1-2-.024v-1.035a1 1 0 0 0-.487-.858L8.46 10.42a3 3 0 0 1-.444-.324 3 3 0 0 1-.443.324l-3.086 1.846a1 1 0 0 0-.487.858v1.047a3.001 3.001 0 1 1-2 0v-1.047a3 3 0 0 1 1.46-2.575l3.086-1.846a1 1 0 0 0 .462-.637A3.006 3.006 0 0 1 7 7.845V5.829a3.001 3.001 0 1 1 2.033-.012z' /></svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="#292D32"/>
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 237.888 237.888" xml:space="preserve">
<g>
<path d="M197.047,59.153C185.153,23.771,153.764,0,118.938,0C82.628,0,50.816,25.12,39.779,62.506
c-2.614,8.849-3.94,18.078-3.94,27.434c0,49.588,37.278,89.931,83.1,89.931c45.827,0,83.11-40.343,83.11-89.931
C202.049,79.352,200.365,68.991,197.047,59.153z M118.938,159.87c-34.793,0-63.1-31.371-63.1-69.931
c0-6.583,0.827-13.078,2.453-19.346h71.861l9.571-20.909l10.073,20.909h29.791c1.626,6.253,2.461,12.736,2.461,19.346
C182.049,128.499,153.737,159.87,118.938,159.87z"/>
<polygon points="64.61,180.791 64.61,237.888 118.61,221.853 172.61,237.888 172.61,180.791 118.61,196.829 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -0,0 +1,46 @@
// Inline SVG icon components — avoids vite-plugin-svgr dependency.
// All icons use currentColor so they inherit the surrounding text color.
export function FlagsIcon({ width = 24, height = 24 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
</svg>
)
}
export function TransferIcon({ width = 24, height = 24 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 17h13M4 17l4-4M4 17l4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20 7H7M20 7l-4-4M20 7l-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
export function MergeIcon({ width = 24, height = 24 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 6H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h3M16 6h3a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-3M12 12v6M12 12l-3-3M12 12l3-3M9 18h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
export function WaiterIcon({ width = 24, height = 24 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="3" stroke="currentColor" strokeWidth="2"/>
<path d="M5 21v-1a7 7 0 0 1 14 0v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
)
}
export function PrintIcon({ width = 24, height = 24 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
</svg>
)
}

View File

@@ -0,0 +1,871 @@
import { useState, useEffect, useCallback } from 'react'
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n) {
if (n === 0) return ''
const s = n > 0 ? `+${n.toFixed(2)}` : `${n.toFixed(2)}`
return s
}
function buildInitialState(product) {
const preferenceSets = product.preference_sets || []
const prefs = {}
const subChoices = {}
const sharedSubs = {}
preferenceSets.forEach(ps => {
const def = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
prefs[ps.id] = def
if (def) {
if (def.sub_choices?.length > 0) {
subChoices[def.id] = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
}
if (ps.shared_subset?.choices?.length > 0 && !def.disables_subset) {
sharedSubs[ps.id] = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
}
}
})
return { prefs, subChoices, sharedSubs }
}
// Build sorted favorites list across all item types
function buildFavorites(product) {
const items = []
;(product.quick_options || []).forEach(q => {
if (q.is_favorite) items.push({ type: 'quick', item: q, sortOrder: q.favorite_sort_order ?? 0 })
})
;(product.ingredients || []).forEach(ing => {
if (ing.is_favorite) items.push({ type: 'ingredient', item: ing, sortOrder: ing.favorite_sort_order ?? 0 })
})
;(product.options || []).forEach(opt => {
if (opt.is_favorite) items.push({ type: 'option', item: opt, sortOrder: opt.favorite_sort_order ?? 0 })
})
;(product.preference_sets || []).forEach(ps => {
if (ps.is_favorite) items.push({ type: 'pref', item: ps, sortOrder: ps.favorite_sort_order ?? 0 })
})
return items.sort((a, b) => a.sortOrder - b.sortOrder)
}
const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
// ── Primitives ────────────────────────────────────────────────────────────────
function Stepper({ value, onChange, min = 0, max = 99 }) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center',
height: 40, borderRadius: 20,
background: 'var(--bg2)', border: '1px solid var(--border)',
overflow: 'hidden',
}} onClick={e => e.stopPropagation()}>
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? 'var(--muted)' : 'var(--text)' }}></button>
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 15, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
<button onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max}
style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? 'var(--muted)' : 'var(--text)' }}>+</button>
</div>
)
}
function CheckCircle({ selected }) {
return (
<div style={{
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
background: selected ? '#f59e0b' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms ease',
}}>
{selected && <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
)
}
function RadioDot({ selected }) {
return (
<div style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms ease',
}}>
{selected && <div style={{ width: 10, height: 10, borderRadius: '50%', background: '#f59e0b' }} />}
</div>
)
}
function Row({ selected, onClick, children, right, left, style = {} }) {
return (
<div onClick={onClick} style={{
padding: '12px 14px',
background: selected ? 'rgba(245,158,11,0.12)' : 'var(--bg2)',
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : 'var(--border)'}`,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 12,
cursor: onClick ? 'pointer' : 'default',
transition: 'background 120ms ease, border-color 120ms ease',
minHeight: 56,
...style,
}}>
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
</div>
)
}
// ── Shared: single quick option row ──────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) {
const qty = quickState[opt.id] || 0
const selected = qty > 0
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
return (
<Row
selected={selected}
onClick={toggleSingle}
left={<CheckCircle selected={selected} />}
right={opt.allow_multiple ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
{selected
? <Stepper value={qty} onChange={v => setQuickState(s => ({ ...s, [opt.id]: v }))} />
: <button
onClick={e => { e.stopPropagation(); setQuickState(s => ({ ...s, [opt.id]: 1 })) }}
style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
</button>
}
</div>
) : null}
>
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>}
</Row>
)
}
// ── Shared: single extra/option row ──────────────────────────────────────────
function ExtraOptionRow({ opt, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const sel = extrasState[opt.id]
const selected = !!sel
const open = expandedExtra === opt.id
const hasSubs = opt.sub_choices?.length > 0
const subLabel = sel ? opt.sub_choices?.find(s => s.name === sel.subName)?.name : null
const toggle = () => {
if (selected) {
setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n })
if (open) setExpandedExtra(null)
} else {
const firstSub = hasSubs ? opt.sub_choices[0] : null
setExtrasState(s => ({ ...s, [opt.id]: { qty: 1, subName: firstSub?.name ?? null } }))
if (hasSubs) setExpandedExtra(opt.id)
}
}
return (
<div>
<Row
selected={selected}
onClick={toggle}
left={<CheckCircle selected={selected} />}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
{opt.allow_multiple && !selected && (
<button
onClick={e => { e.stopPropagation(); toggle() }}
style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
</button>
)}
{selected && opt.allow_multiple && (
<Stepper value={sel.qty} onChange={v => {
if (v === 0) { setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n }); return }
setExtrasState(s => ({ ...s, [opt.id]: { ...sel, qty: v } }))
}} />
)}
{selected && hasSubs && (
<button onClick={e => { e.stopPropagation(); setExpandedExtra(open ? null : opt.id) }}
style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
}
>
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
{(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)}` : 'Included'}
{subLabel && <span style={{ color: '#f59e0b', fontWeight: 600 }}> · {subLabel}</span>}
</div>
</Row>
{selected && open && hasSubs && (
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '6px 2px 2px' }}>Επιλογή</div>
{opt.sub_choices.map((sub, si) => {
const isSel = sel.subName === sub.name
return (
<Row key={si} selected={isSel}
onClick={() => setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))}
left={<RadioDot selected={isSel} />}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} </div>}
</div>
</Row>
)
})}
</div>
)}
</div>
)
}
// ── Shared: single ingredient row ─────────────────────────────────────────────
function IngredientRow({ ing, removedState, setRemovedState }) {
const removed = !!removedState[ing.id]
return (
<Row selected={false}
onClick={() => setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
right={
<div style={{
height: 34, padding: '0 14px', borderRadius: 17,
background: removed ? 'var(--danger)' : 'var(--bg3)',
border: `1px solid ${removed ? 'var(--danger)' : 'var(--border)'}`,
color: removed ? '#fff' : 'var(--text)',
fontSize: 13, fontWeight: 600,
display: 'inline-flex', alignItems: 'center',
transition: 'all 120ms ease',
}}>
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
</div>
}>
<div style={{ fontSize: 15, fontWeight: 500, color: removed ? 'var(--muted)' : 'var(--text)', textDecoration: removed ? 'line-through' : 'none', transition: 'all 120ms' }}>{ing.name}</div>
</Row>
)
}
// ── Shared: single preference set ─────────────────────────────────────────────
function PrefSetBlock({ ps, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const selChoice = prefs[ps.id] ?? null
const complete = selChoice != null
&& !(selChoice.sub_choices?.length > 0 && subChoices[selChoice.id] == null)
&& !(ps.shared_subset?.choices?.length > 0 && !selChoice.disables_subset && sharedSubs[ps.id] == null)
const showShared = ps.shared_subset?.choices?.length > 0 && selChoice != null && !selChoice.disables_subset
function selectPref(choice) {
setPrefs(p => ({ ...p, [ps.id]: choice }))
if (choice?.sub_choices?.length > 0) {
setSubChoices(s => ({ ...s, [choice.id]: s[choice.id] ?? (choice.sub_choices.find(x => x.is_default) ?? choice.sub_choices[0]) }))
}
if (ps.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
setSharedSubs(s => s[ps.id] != null ? s : { ...s, [ps.id]: ps.shared_subset.choices.find(x => x.is_default) ?? ps.shared_subset.choices[0] })
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, padding: '0 2px 10px' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: complete ? 'var(--text)' : '#ef4444' }}>{ps.name}</div>
{!complete && <div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: 0.6 }}>Απαιτείται</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ps.choices.map(ch => {
const isSel = selChoice?.id === ch.id
const hasSubs = ch.sub_choices?.length > 0
const subMissing = isSel && hasSubs && subChoices[ch.id] == null
return (
<div key={ch.id}>
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--muted)' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{ch.name}</div>
</Row>
{isSel && hasSubs && (
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: `2px solid ${subMissing ? '#ef4444' : 'rgba(245,158,11,0.3)'}`, display: 'flex', flexDirection: 'column', gap: 6 }}>
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '4px 2px 2px' }}> απαιτείται επιλογή</p>}
{ch.sub_choices.map((sub, si) => {
const subSel = subChoices[ch.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
)
})}
{showShared && (
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>{ps.shared_subset.name}</div>
{ps.shared_subset.choices.map((sub, si) => {
const subSel = sharedSubs[ps.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
</div>
)
}
// ── Tab: Favorites ────────────────────────────────────────────────────────────
function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtrasState, expandedExtra, setExpandedExtra, removedState, setRemovedState, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const favorites = buildFavorites(product)
if (favorites.length === 0) return (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν αγαπημένα για αυτό το προϊόν.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{favorites.map((fav, fi) => {
if (fav.type === 'quick') {
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} />
}
if (fav.type === 'ingredient') {
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
}
if (fav.type === 'option') {
return <ExtraOptionRow key={`opt-${fav.item.id}`} opt={fav.item} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
}
if (fav.type === 'pref') {
return (
<PrefSetBlock key={`pref-${fav.item.id}`} ps={fav.item}
prefs={prefs} setPrefs={setPrefs}
subChoices={subChoices} setSubChoices={setSubChoices}
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
/>
)
}
return null
})}
</div>
)
}
// ── Tab: Quick Options ────────────────────────────────────────────────────────
function QuickTab({ product, quickState, setQuickState }) {
const quickOptions = product.quick_options || []
if (quickOptions.length === 0) return (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{quickOptions.map(opt => (
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} />
))}
</div>
)
}
// ── Tab: Extras ───────────────────────────────────────────────────────────────
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const options = product.options || []
if (options.length === 0) return (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{options.map(opt => (
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
))}
</div>
)
}
// ── Tab: Υλικά (Ingredients) ─────────────────────────────────────────────────
function IngredientsTab({ product, removedState, setRemovedState }) {
const ingredients = product.ingredients || []
if (ingredients.length === 0) return (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 4 }}>
Πατήστε για να αφαιρέσετε υλικό από το πιάτο.
</div>
{ingredients.map(ing => (
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
))}
</div>
)
}
// ── Tab: Προτιμήσεις ─────────────────────────────────────────────────────────
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const preferenceSets = product.preference_sets || []
if (preferenceSets.length === 0) return (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{preferenceSets.map(ps => (
<PrefSetBlock key={ps.id} ps={ps}
prefs={prefs} setPrefs={setPrefs}
subChoices={subChoices} setSubChoices={setSubChoices}
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
/>
))}
</div>
)
}
// ── Tab: Notes ────────────────────────────────────────────────────────────────
function NotesTab({ note, setNote }) {
return (
<div>
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>
Οτιδήποτε ειδικό για την κουζίνα.
</div>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
rows={5}
style={{ width: '100%', padding: 14, fontSize: 15, fontFamily: 'inherit', color: 'var(--text)', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 20, marginBottom: 8 }}>Γρήγορες σημειώσεις</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{QUICK_NOTES.map(q => (
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
style={{ height: 36, padding: '0 14px', borderRadius: 18, background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text)', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
+ {q}
</button>
))}
</div>
</div>
)
}
// ── Tab: Summary ──────────────────────────────────────────────────────────────
function SummaryTab({ product, summaryLines, note, onJumpTab }) {
const isEmpty = summaryLines.length === 0 && !note
const byGroup = { quick: [], extras: [], removed: [], prefs: [] }
summaryLines.forEach(l => byGroup[l.group]?.push(l))
const Section = ({ title, tab, lines }) => lines.length === 0 ? null : (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{title}</div>
<button onClick={() => onJumpTab(tab)} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{lines.map((l, i) => (
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
{l.label}
</div>
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>}
</div>
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} </div>}
</div>
))}
</div>
</div>
)
return (
<div>
{isEmpty ? (
<div style={{ padding: '40px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: 14 }}>
Δεν έχει γίνει καμία προσαρμογή. Χρησιμοποιήστε τις καρτέλες για να διαμορφώσετε το προϊόν.
</div>
) : (
<>
<Section title="Προτιμήσεις" tab="prefs" lines={byGroup.prefs} />
<Section title="Γρήγορες Επιλογές" tab="quick" lines={byGroup.quick} />
<Section title="Extras" tab="extras" lines={byGroup.extras} />
<Section title="Αφαιρέθηκαν" tab="ingredients" lines={byGroup.removed} />
{note && (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Σημείωση</div>
<button onClick={() => onJumpTab('notes')} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
</div>
<div style={{ padding: '12px 14px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 10, fontSize: 14, color: 'var(--text)', lineHeight: 1.4, whiteSpace: 'pre-wrap' }}>{note}</div>
</div>
)}
</>
)}
</div>
)
}
// ── Main OrderDrawer ──────────────────────────────────────────────────────────
export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialState }) {
const preferenceSets = product?.preference_sets || []
const quickOptions = product?.quick_options || []
const options = product?.options || []
const ingredients = product?.ingredients || []
const favorites = product ? buildFavorites(product) : []
const hasTabs = {
favorites: favorites.length > 0,
quick: quickOptions.length > 0,
extras: options.length > 0,
ingredients: ingredients.length > 0,
prefs: preferenceSets.length > 0,
}
const firstTab = hasTabs.favorites ? 'favorites'
: hasTabs.quick ? 'quick'
: hasTabs.extras ? 'extras'
: hasTabs.ingredients ? 'ingredients'
: hasTabs.prefs ? 'prefs'
: 'notes'
const [activeTab, setActiveTab] = useState(firstTab)
const [qty, setQty] = useState(1)
const [quickState, setQuickState] = useState({})
const [extrasState, setExtrasState] = useState({})
const [expandedExtra, setExpandedExtra] = useState(null)
const [removedState, setRemovedState] = useState({})
const [prefs, setPrefs] = useState({})
const [subChoices, setSubChoices] = useState({})
const [sharedSubs, setSharedSubs] = useState({})
const [note, setNote] = useState('')
const [addAttempted, setAddAttempted] = useState(false)
// Reset/init when drawer opens or product changes
useEffect(() => {
if (!isOpen || !product) return
const base = buildInitialState(product)
if (initialState) {
setQty(initialState.qty ?? 1)
setQuickState(initialState.quickState ?? {})
setExtrasState(initialState.extrasState ?? {})
setRemovedState(initialState.removedState ?? {})
setPrefs(initialState.prefs ?? base.prefs)
setSubChoices(initialState.subChoices ?? base.subChoices)
setSharedSubs(initialState.sharedSubs ?? base.sharedSubs)
setNote(initialState.note ?? '')
} else {
setQty(1)
setQuickState({})
setExtrasState({})
setRemovedState({})
setPrefs(base.prefs)
setSubChoices(base.subChoices)
setSharedSubs(base.sharedSubs)
setNote('')
}
setExpandedExtra(null)
setAddAttempted(false)
setActiveTab(initialState?.activeTab ?? firstTab)
}, [isOpen, product?.id])
// Derived: summary lines + price
const { summaryLines, totalPrice } = (() => {
if (!product) return { summaryLines: [], totalPrice: 0 }
let price = product.base_price
const lines = []
preferenceSets.forEach(ps => {
const choice = prefs[ps.id]
if (!choice) return
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null })
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
price += delta
})
quickOptions.forEach(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return
const linePrice = opt.price * q
lines.push({ group: 'quick', label: opt.name, qty: q, price: linePrice, detail: null })
price += linePrice
})
options.forEach(opt => {
const sel = extrasState[opt.id]
if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null })
price += linePrice
})
ingredients.forEach(ing => {
if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null })
})
return { summaryLines: lines, totalPrice: price * qty }
})()
// Validation
function isPrefComplete(ps) {
const choice = prefs[ps.id]
if (!choice) return false
if (choice.sub_choices?.length > 0 && subChoices[choice.id] == null) return false
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && sharedSubs[ps.id] == null) return false
return true
}
const allPrefsOk = preferenceSets.every(isPrefComplete)
const extrasSubsMissing = options.some(opt => {
const sel = extrasState[opt.id]
return sel && opt.sub_choices?.length > 0 && sel.subName == null
})
const canAdd = allPrefsOk && !extrasSubsMissing
const prefsHasMandatory = hasTabs.prefs && preferenceSets.some(ps => ps.default_choice_id == null)
const prefsTabAlert = hasTabs.prefs && !allPrefsOk && (addAttempted || prefsHasMandatory)
// Also alert the favorites tab if it contains an incomplete pref
const favHasIncompletePref = hasTabs.favorites && !allPrefsOk && favorites.some(f => f.type === 'pref' && !isPrefComplete(f.item))
const favTabAlert = favHasIncompletePref && (addAttempted || prefsHasMandatory)
function handleAdd() {
if (!canAdd) {
setAddAttempted(true)
if (!allPrefsOk) {
// Jump to favorites if the incomplete pref is there, else prefs tab
if (favHasIncompletePref) setActiveTab('favorites')
else if (hasTabs.prefs) setActiveTab('prefs')
}
return
}
const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id]
if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = sharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
}
return entries
})
const optionEntries = options.flatMap(opt => {
const sel = extrasState[opt.id]
if (!sel) return []
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const entries = []
for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
}
return entries
})
const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 }))
})
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
onAdd({
product_id: product.id,
quantity: qty,
selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
removed_ingredients: removedNames,
notes: note,
_drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note },
})
onClose()
}
// Tabs definition
const tabs = [
hasTabs.favorites && { id: 'favorites', label: '♥ Αγαπ.' },
hasTabs.quick && { id: 'quick', label: 'Quick' },
hasTabs.extras && { id: 'extras', label: 'Extras' },
hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
hasTabs.prefs && { id: 'prefs', label: 'Προτιμ.' },
{ id: 'notes', label: 'Note' },
{ id: 'summary', label: 'Summary' },
].filter(Boolean)
const badgeFor = id => {
if (id === 'favorites') {
// count favorited items that have been interacted with
const favQuick = favorites.filter(f => f.type === 'quick' && (quickState[f.item.id] || 0) > 0).length
const favIng = favorites.filter(f => f.type === 'ingredient' && removedState[f.item.id]).length
const favExt = favorites.filter(f => f.type === 'option' && extrasState[f.item.id]).length
const favPref = favorites.filter(f => f.type === 'pref' && isPrefComplete(f.item)).length
return favQuick + favIng + favExt + favPref
}
if (id === 'quick') return Object.values(quickState).filter(v => v > 0).length
if (id === 'extras') return Object.values(extrasState).filter(Boolean).length
if (id === 'ingredients') return Object.values(removedState).filter(Boolean).length
if (id === 'prefs') return preferenceSets.filter(isPrefComplete).length
if (id === 'notes') return note ? 1 : 0
if (id === 'summary') return summaryLines.length + (note ? 1 : 0)
return 0
}
if (!product) return null
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
opacity: isOpen ? 1 : 0,
pointerEvents: isOpen ? 'auto' : 'none',
transition: 'opacity 260ms ease',
zIndex: 40,
}} />
{/* Sheet */}
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0,
height: '92svh',
background: 'var(--bg)',
borderRadius: '20px 20px 0 0',
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 320ms cubic-bezier(0.32, 0.72, 0, 1)',
zIndex: 41,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 -8px 40px rgba(0,0,0,0.5)',
}}>
{/* Grab handle */}
<div style={{ padding: '10px 0 4px', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--border)' }} />
</div>
{/* Header */}
<div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{product.image_url && (
<img src={`${import.meta.env.VITE_API_URL || ''}${product.image_url}`} alt=""
style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'cover', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', lineHeight: 1.2 }}>{product.name}</div>
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>{product.base_price.toFixed(2)} </div>
</div>
<button onClick={onClose} style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="var(--text)" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Tabs bar */}
<div style={{ marginTop: 12, borderBottom: '1px solid var(--border)', overflowX: 'auto', scrollbarWidth: 'none', flexShrink: 0 }}>
<div style={{ display: 'flex', padding: '0 12px', gap: 2, minWidth: 'max-content' }}>
{tabs.map(t => {
const active = activeTab === t.id
const badge = badgeFor(t.id)
const isAlert = (t.id === 'prefs' && prefsTabAlert) || (t.id === 'favorites' && favTabAlert)
const tabColor = isAlert ? '#f59e0b' : active ? '#f59e0b' : 'var(--muted)'
return (
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
padding: '12px 6px',
background: 'none', border: 'none',
borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
color: tabColor,
fontSize: 14, fontWeight: (active || isAlert) ? 700 : 500,
fontFamily: 'inherit', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 5,
whiteSpace: 'nowrap', marginRight: 8,
transition: 'color 120ms ease, border-color 120ms ease',
animation: isAlert ? 'tab-pulse 0.9s ease-in-out 3' : 'none',
}}>
{t.label}
{badge > 0 && !isAlert && (
<span style={{
minWidth: 18, height: 18, padding: '0 5px',
borderRadius: 9,
background: active ? 'var(--accent)' : 'var(--bg3)',
color: active ? 'var(--accent-fg)' : 'var(--muted)',
fontSize: 11, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontVariantNumeric: 'tabular-nums',
}}>{badge}</span>
)}
</button>
)
})}
</div>
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 16px 20px', background: 'var(--bg)', WebkitOverflowScrolling: 'touch' }}>
{activeTab === 'favorites' && (
<FavoritesTab
product={product}
quickState={quickState} setQuickState={setQuickState}
extrasState={extrasState} setExtrasState={setExtrasState}
expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra}
removedState={removedState} setRemovedState={setRemovedState}
prefs={prefs} setPrefs={setPrefs}
subChoices={subChoices} setSubChoices={setSubChoices}
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
/>
)}
{activeTab === 'quick' && <QuickTab product={product} quickState={quickState} setQuickState={setQuickState} />}
{activeTab === 'extras' && <ExtrasTab product={product} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />}
{activeTab === 'ingredients' && <IngredientsTab product={product} removedState={removedState} setRemovedState={setRemovedState} />}
{activeTab === 'prefs' && <PrefsTab product={product} prefs={prefs} setPrefs={setPrefs} subChoices={subChoices} setSubChoices={setSubChoices} sharedSubs={sharedSubs} setSharedSubs={setSharedSubs} />}
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
{activeTab === 'summary' && <SummaryTab product={product} summaryLines={summaryLines} note={note} onJumpTab={setActiveTab} />}
</div>
{/* Footer: qty stepper + ΠΡΟΣΘΗΚΗ */}
<div style={{ padding: '12px 16px 20px', background: 'var(--bg2)', borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, boxShadow: '0 -4px 16px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', height: 52, borderRadius: 26, background: 'var(--bg3)', border: '1px solid var(--border)', overflow: 'hidden', flexShrink: 0 }}>
<button onClick={() => setQty(q => Math.max(1, q - 1))} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? 'var(--muted)' : 'var(--text)' }}></button>
<div style={{ minWidth: 32, textAlign: 'center', fontSize: 17, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{qty}</div>
<button onClick={() => setQty(q => q + 1)} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: 'pointer', color: 'var(--text)' }}>+</button>
</div>
<button onClick={handleAdd} disabled={!canAdd} style={{
flex: 1, height: 52, borderRadius: 26,
background: canAdd ? 'var(--accent)' : 'var(--bg3)',
border: 'none', color: canAdd ? 'var(--accent-fg)' : 'var(--muted)',
fontSize: 16, fontWeight: 700, fontFamily: 'inherit',
cursor: canAdd ? 'pointer' : 'not-allowed',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 20px',
transition: 'background 150ms ease',
}}>
<span>ΠΡΟΣΘΗΚΗ</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{totalPrice.toFixed(2)} </span>
</button>
</div>
</div>
</>
)
}

View File

@@ -1,21 +1,58 @@
import { useRef } from 'react'
function fmtPrice(v) {
return Number(v).toFixed(2) + ' €'
}
function ItemRow({ item, selectable, selected, onToggle }) {
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
const isPaid = item.status === 'paid'
const isCancelled = item.status === 'cancelled'
const isStacked = item.quantity > 1
let opts = []
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {}
let removed = []
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
// Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll)
const pressTimer = useRef(null)
const didLongPress = useRef(false)
const touchStartPos = useRef({ x: 0, y: 0 })
function handleTouchStart(e) {
if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return
didLongPress.current = false
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer.current = setTimeout(() => {
didLongPress.current = true
onLongPress(item)
}, 500)
}
function handleTouchMove(e) {
const dx = Math.abs(e.touches[0].clientX - touchStartPos.current.x)
const dy = Math.abs(e.touches[0].clientY - touchStartPos.current.y)
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
}
function handleTouchEnd() {
clearTimeout(pressTimer.current)
}
function handleClick() {
if (didLongPress.current) { didLongPress.current = false; return }
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
}
return (
<div
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''}`}
onClick={selectable && !isPaid && !isCancelled ? () => onToggle(item.id) : undefined}
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default' }}
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }}
>
<div className="order-item__row">
{selectable && !isPaid && !isCancelled && (
@@ -26,8 +63,11 @@ function ItemRow({ item, selectable, selected, onToggle }) {
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
<span className="order-item__qty">×{item.quantity}</span>
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
{isPaid && <span className="badge badge--paid">Πληρωμένο</span>}
{isCancelled && <span className="badge badge--cancelled">Ακυρώθηκε</span>}
{isPaid && <span className="badge badge--paid">Paid</span>}
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
{!isPaid && !isCancelled && !item.printed && (
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα"></span>
)}
</div>
{opts.map((o, i) => <div key={i} className="order-item__modifier">+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}</div>)}
{removed.map((r, i) => <div key={i} className="order-item__modifier">- {r}</div>)}
@@ -36,26 +76,45 @@ function ItemRow({ item, selectable, selected, onToggle }) {
)
}
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle }) {
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem }) {
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = activeItems
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const paidTotal = activeItems
.filter(i => i.status === 'paid')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
return (
<div className="order-summary">
{activeItems.length === 0 && <p style={{ color: '#64748b', textAlign: 'center' }}>Δεν υπάρχουν αντικείμενα</p>}
{activeItems.map(item => (
{activeItems.map((item, idx) => (
<ItemRow
key={item.id}
item={item}
selectable={selectable}
selected={selectedIds.includes(item.id)}
onToggle={onToggle}
onLongPress={onLongPressItem}
isLast={idx === activeItems.length - 1}
/>
))}
<div className="order-summary__total">
<span>Σύνολο</span>
<span>{fmtPrice(total)}</span>
</div>
{paidTotal > 0 && paidTotal < total && (
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#64748b' }}>
<span>Πληρωμένο</span>
<span style={{ color: '#22c55e' }}>{fmtPrice(paidTotal)}</span>
</div>
)}
{paidTotal > 0 && paidTotal < total && (
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#94a3b8' }}>
<span>Εκκρεμεί</span>
<span style={{ color: '#f59e0b', fontWeight: 700 }}>{fmtPrice(total - paidTotal)}</span>
</div>
)}
</div>
)
}

View File

@@ -29,7 +29,12 @@ export default function PinPad({ onSubmit, loading }) {
{[1,2,3,4,5,6,7,8,9].map(d => (
<button key={d} onClick={() => press(String(d))} className="pin-btn">{d}</button>
))}
<button onClick={backspace} className="pin-btn pin-btn--secondary"></button>
<button onClick={backspace} className="pin-btn pin-btn--secondary">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="currentColor"/>
<path fillRule="evenodd" clipRule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="currentColor"/>
</svg>
</button>
<button onClick={() => press('0')} className="pin-btn">0</button>
<button onClick={submit} className="pin-btn pin-btn--confirm" disabled={loading || pin.length === 0}>
{loading ? '…' : '✓'}

View File

@@ -1,5 +1,18 @@
import { useState } from 'react'
import ItemOptionsModal from './ItemOptionsModal'
import OrderDrawer from './OrderDrawer'
function CategoriesIcon({ width = 20, height = 20 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
)
}
const API_URL = import.meta.env.VITE_API_URL || ''
function hexToRgba(hex, alpha) {
if (!hex) return null
@@ -10,66 +23,209 @@ function hexToRgba(hex, alpha) {
return `rgba(${r},${g},${b},${alpha})`
}
export default function ProductPicker({ categories, products, onAdd }) {
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
const [selectedProduct, setSelectedProduct] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
function ProductGrid({ products, onOpen }) {
if (products.length === 0) return null
return (
<div className="product-grid">
{products.map(product => {
const initials = product.name
.trim()
.split(/\s+/)
.slice(0, 2)
.map(w => w[0])
.join('')
.toUpperCase()
return (
<button key={product.id} className="product-btn" onClick={() => onOpen(product)}>
<div className="product-btn__thumb">
<div className="product-btn__thumb-inner">
{product.image_url
? <img src={`${API_URL}${product.image_url}`} alt="" className="product-btn__img" />
: <span className="product-btn__initials">{initials}</span>
}
</div>
</div>
<div className="product-btn__info">
<span className="product-btn__name">{product.name}</span>
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} </span>
</div>
</button>
)
})}
</div>
)
}
const filtered = products.filter(p => p.category_id === activeCat)
// Builds the ordered list of sections for a top-level category:
// interleaves direct products (as a "General" section) and sub-categories
// according to general_sort_order and each sub's sort_order.
function buildSections(parent, subcategories, directProducts) {
const sections = []
if (directProducts.length > 0) {
sections.push({ _isGeneral: true, sort_order: parent.general_sort_order, products: directProducts })
}
for (const sub of subcategories) {
sections.push({ ...sub, _isGeneral: false, sort_order: sub.sort_order })
}
return sections.sort((a, b) => a.sort_order - b.sort_order)
}
export default function ProductPicker({ categories, products, onAdd }) {
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const initialCatId = topLevel[0]?.id ?? null
const [activeCat, setActiveCat] = useState(initialCatId)
const [drawerProduct, setDrawerProduct] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
const [expandedSubs, setExpandedSubs] = useState(() => {
if (!initialCatId) return {}
const subs = categories.filter(c => c.parent_id === initialCatId)
const state = {}
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
return state
})
const activeParent = categories.find(c => c.id === activeCat)
const subcategories = activeParent
? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
: []
const hasSubcats = subcategories.length > 0
// Products directly on this top-level category (no sub-cat)
const directProducts = products.filter(p => p.category_id === activeCat)
// Products for the flat view (no sub-cats)
const flatProducts = products.filter(p => p.category_id === activeCat)
// Build sections for accordion view
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
function buildDefaultExpanded(catId) {
const subs = categories.filter(c => c.parent_id === catId)
const state = {}
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
return state
}
function selectCategory(id) {
setActiveCat(id)
setViewAllOpen(false)
setExpandedSubs(buildDefaultExpanded(id))
}
function toggleSub(key) {
setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))
}
function openDrawer(product) { setDrawerProduct(product) }
function closeDrawer() { setDrawerProduct(null) }
return (
<div className="product-picker">
<div className="category-tabs">
{/* View All button — always first */}
<button
className="cat-tab cat-tab--viewall"
onClick={() => setViewAllOpen(true)}
title="Εμφάνιση όλων"
>
</button>
<div className="category-tabs__sticky">
<button
className="cat-tab cat-tab--viewall"
onClick={() => setViewAllOpen(true)}
title="Εμφάνιση όλων"
>
<CategoriesIcon width="20" height="20" />
</button>
</div>
{categories.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
: isActive ? 'var(--accent)' : 'var(--bg3)'
const color = cat.color
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
: isActive ? '#1c1400' : 'var(--muted)'
return (
<button
key={cat.id}
className="cat-tab"
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
onClick={() => setActiveCat(cat.id)}
>
{cat.name}
</button>
)
})}
<div className="category-tabs__scroll-wrap">
<div className="category-tabs__fade" />
<div className="category-tabs__scroll">
{topLevel.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
: isActive ? 'var(--accent)' : 'var(--bg3)'
const color = cat.color
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
: isActive ? 'var(--accent-fg)' : 'var(--muted)'
return (
<button
key={cat.id}
className="cat-tab"
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
onClick={() => selectCategory(cat.id)}
>
{cat.name}
</button>
)
})}
</div>
</div>
</div>
<div className="product-grid">
{filtered.map(product => (
<button key={product.id} className="product-btn" onClick={() => setSelectedProduct(product)}>
<span className="product-btn__name">{product.name}</span>
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} </span>
</button>
))}
{filtered.length === 0 && (
<p style={{ color: '#64748b', gridColumn: '1/-1', textAlign: 'center', padding: 32 }}>
Δεν υπάρχουν προϊόντα
</p>
{/* Product area — flat grid or accordion depending on sub-cats */}
<div className="product-area">
{!hasSubcats ? (
// No sub-categories: original flat grid
<>
<ProductGrid products={flatProducts} onOpen={openDrawer} />
{flatProducts.length === 0 && (
<p style={{ color: '#64748b', textAlign: 'center', padding: 32 }}>
Δεν υπάρχουν προϊόντα
</p>
)}
</>
) : (
// Has sub-categories: accordion view
<div className="subcat-accordion">
{sections.map(section => {
const key = section._isGeneral ? '__general__' : String(section.id)
const isOpen = !!expandedSubs[key]
const sectionProducts = section._isGeneral
? section.products
: products.filter(p => p.category_id === section.id)
if (sectionProducts.length === 0) return null
// General products appear flat — no collapsible header
if (section._isGeneral) {
return (
<div key={key} className="subcat-general">
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
</div>
)
}
const accentColor = section.color ?? activeParent?.color ?? null
return (
<div key={key} className="subcat-section">
<button
className={`subcat-header ${isOpen ? 'subcat-header--open' : ''}`}
onClick={() => toggleSub(key)}
>
{accentColor && <span className="subcat-header__pill" style={{ background: accentColor }} />}
<span className="subcat-header__name">{section.name}</span>
<span className="subcat-header__count">{sectionProducts.length}</span>
<svg
className="subcat-header__chevron"
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
width="16" height="16" viewBox="0 0 24 24" fill="none"
>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{isOpen && (
<div className="subcat-body">
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* View All modal */}
{/* View All modal — top-level categories only */}
{viewAllOpen && (
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
<div
@@ -81,7 +237,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
<button className="icon-btn" onClick={() => setViewAllOpen(false)}></button>
</div>
<div className="cat-all-grid">
{categories.map(cat => {
{topLevel.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color || 'var(--bg3)'
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
@@ -102,13 +258,12 @@ export default function ProductPicker({ categories, products, onAdd }) {
</div>
)}
{selectedProduct && (
<ItemOptionsModal
product={selectedProduct}
onAdd={onAdd}
onClose={() => setSelectedProduct(null)}
/>
)}
<OrderDrawer
product={drawerProduct}
isOpen={!!drawerProduct}
onClose={closeDrawer}
onAdd={item => { onAdd(item); closeDrawer() }}
/>
</div>
)
}

View File

@@ -1,24 +1,196 @@
export default function TableCard({ table, order, currentUserId, onClick }) {
const hasOrder = !!order
const isMyTable = hasOrder && order.waiters?.some(w => w.waiter_id === currentUserId)
import { useRef, useState } from 'react'
import useThemeStore from '../store/themeStore'
import useTableColourStore from '../store/tableColourStore'
let statusLabel = 'Ελεύθερο'
let cardClass = 'table-card table-card--free'
const STATUS_LABELS = {
free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ',
mine: 'ΔΙΚΟ ΜΟΥ',
paid: 'ΠΛΗΡΩΜΕΝΟ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
}
if (hasOrder && isMyTable) {
statusLabel = 'Δικό μου'
cardClass = 'table-card table-card--mine'
} else if (hasOrder) {
statusLabel = 'Ενεργό'
cardClass = 'table-card table-card--active'
}
const DRAG_THRESHOLD = 8
const HOLD_MS = 480
export default function TableCard({ table, order, isMine, flags = [], groupName = '', onClick, onLongPress }) {
const holdTimer = useRef(null)
const startPos = useRef({ x: 0, y: 0 })
const didFire = useRef(false)
const [showTip, setShowTip] = useState(false)
const dark = useThemeStore(s => s.dark)
const colours = useTableColourStore(s => s.colours)
let statusKey = 'free'
if (order?.status === 'paid') statusKey = 'paid'
else if (order?.status === 'partially_paid') statusKey = 'partially_paid'
else if (order && isMine) statusKey = 'mine'
else if (order) statusKey = 'open'
const mode = dark ? 'dark' : 'light'
const cfg = colours[mode][statusKey]
const displayName = table.label || `T${table.number}`
function cancel() {
clearTimeout(holdTimer.current)
holdTimer.current = null
}
function onTouchStart(e) {
const t = e.touches[0]
startPos.current = { x: t.clientX, y: t.clientY }
didFire.current = false
holdTimer.current = setTimeout(() => {
didFire.current = true
if (onLongPress) onLongPress()
else setShowTip(true)
}, HOLD_MS)
}
function onTouchMove(e) {
if (!holdTimer.current) return
const t = e.touches[0]
const dx = Math.abs(t.clientX - startPos.current.x)
const dy = Math.abs(t.clientY - startPos.current.y)
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
}
function onTouchEnd() {
cancel()
setShowTip(false)
}
function onMouseDown(e) {
startPos.current = { x: e.clientX, y: e.clientY }
didFire.current = false
holdTimer.current = setTimeout(() => {
didFire.current = true
if (onLongPress) onLongPress()
else setShowTip(true)
}, HOLD_MS)
}
function onMouseMove(e) {
if (!holdTimer.current) return
const dx = Math.abs(e.clientX - startPos.current.x)
const dy = Math.abs(e.clientY - startPos.current.y)
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
}
function onMouseUp() { cancel(); setShowTip(false) }
function onMouseLeave() { cancel(); setShowTip(false) }
function handleClick(e) {
if (didFire.current) { e.preventDefault(); return }
onClick?.()
}
return (
<button className={cardClass} onClick={onClick}>
<span className="table-card__number">{displayName}</span>
<span className="table-card__status">{statusLabel}</span>
</button>
<div style={{ position: 'relative' }}>
<button
className="table-card-v2"
style={{ background: cfg.cardBg }}
onClick={handleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
>
{/* Top-left: table name + area */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '65%' }}>
<span style={{
fontSize: 'clamp(22px, 5.5vw, 36px)',
fontWeight: 800,
lineHeight: 1.05,
color: cfg.nameText,
letterSpacing: -0.5,
}}>
{displayName}
</span>
{groupName && (
<span style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: 0.8,
color: cfg.nameText + '80',
marginTop: 1,
textTransform: 'uppercase',
}}>
{groupName}
</span>
)}
</div>
{/* Bottom-left: status badge */}
<div style={{
position: 'absolute', bottom: 11, left: 11,
background: cfg.badgeBg,
borderRadius: 5,
padding: '2px 8px',
}}>
<span style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.5,
color: cfg.badgeText,
whiteSpace: 'nowrap',
}}>
{STATUS_LABELS[statusKey]}
</span>
</div>
{/* Bottom-right: flag circles, stacked, up to 3 visible */}
{flags.length > 0 && (
<div style={{
position: 'absolute', bottom: 8, right: 10,
display: 'flex', flexDirection: 'column-reverse', gap: 4,
}}>
{flags.slice(0, 3).map(f => (
<div key={f.id} style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
}}>
{f.emoji || '🏷️'}
</div>
))}
{flags.length > 3 && (
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: '#fff',
}}>
+{flags.length - 3}
</div>
)}
</div>
)}
</button>
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
{showTip && flags.length > 0 && (
<div style={{
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 10, padding: '8px 12px', zIndex: 50,
boxShadow: '0 4px 16px var(--shadow)',
minWidth: 160,
pointerEvents: 'none',
}}>
{flags.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<span style={{ fontSize: 15 }}>{f.emoji || '🏷️'}</span>
<span style={{ fontSize: 13, color: 'var(--text)' }}>{f.name}</span>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAuthStore from '../store/authStore'
import useShiftStore from '../store/shiftStore'
import useThemeStore from '../store/themeStore'
import client from '../api/client'
function formatTime(iso) {
if (!iso) return ''
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function formatDuration(iso) {
if (!iso) return ''
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
if (mins < 60) return `${mins}λ`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
}
export default function UserMenu() {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const ref = useRef(null)
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { dark, toggle } = useThemeStore()
const {
shift, selfEndAllowed,
setShift, clearShift,
} = useShiftStore()
useEffect(() => {
function onClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
function handleLogout() {
setOpen(false)
logout()
navigate('/login')
}
const activeBreak = shift?.breaks?.find(b => !b.ended_at)
const isWaiter = user?.role === 'waiter'
async function handleEndShift() {
if (!window.confirm('Να τελειώσει η βάρδια σου;')) return
setBusy(true)
try {
await client.post('/api/shifts/end', {})
clearShift()
setOpen(false)
} catch {
// ignore — gate will re-check
} finally {
setBusy(false)
}
}
async function handleBreak() {
setBusy(true)
try {
if (activeBreak) {
await client.post(`/api/shifts/${shift.id}/break/end`)
} else {
await client.post(`/api/shifts/${shift.id}/break/start`)
}
const res = await client.get('/api/shifts/my')
setShift(res.data)
} catch {
// ignore
} finally {
setBusy(false)
}
}
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
className="icon-btn"
onClick={() => setOpen(o => !o)}
title="Μενού χρήστη"
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
>
{/* Break indicator dot */}
{activeBreak && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: 'var(--accent)', flexShrink: 0,
animation: 'tab-pulse 1.5s ease-in-out infinite',
}} />
)}
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{user?.username}</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="user-menu-dropdown">
{/* ── Shift info (waiters only) ─────────────────────── */}
{isWaiter && shift && (
<>
<div style={{
padding: '12px 16px',
background: 'var(--bg3)',
borderRadius: 10,
margin: '4px 8px 2px',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 4 }}>
Βάρδια ενεργή
</div>
<div style={{ fontSize: 13, color: 'var(--text)', display: 'flex', justifyContent: 'space-between' }}>
<span>Από {formatTime(shift.started_at)}</span>
<span style={{ color: 'var(--muted)' }}>{formatDuration(shift.started_at)}</span>
</div>
{shift.starting_cash != null && (
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
Αρχικά: {shift.starting_cash.toFixed(2)}
</div>
)}
{activeBreak && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 4, fontWeight: 600 }}>
Σε διάλειμμα από {formatTime(activeBreak.started_at)}
</div>
)}
</div>
{/* Break button */}
<button
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
onClick={handleBreak}
disabled={busy}
>
<span className="user-menu-item__icon">{activeBreak ? '▶' : '☕'}</span>
<span>{activeBreak ? 'Τέλος Διαλείμματος' : 'Διάλειμμα'}</span>
</button>
{/* End shift button */}
{selfEndAllowed ? (
<button
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
onClick={handleEndShift}
disabled={busy}
style={{ color: 'var(--danger)' }}
>
<span className="user-menu-item__icon"></span>
<span>Τέλος Βάρδιας</span>
</button>
) : (
<div style={{ padding: '8px 16px', fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
Ζητήστε από τον διαχειριστή να κλείσει τη βάρδια
</div>
)}
<div className="user-menu-divider" />
</>
)}
{/* ── Theme toggle ──────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}>
<span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span>
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
</button>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
<span className="user-menu-item__icon"></span>
<span>Αποσύνδεση</span>
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import useAuthStore from '../store/authStore'
import client from '../api/client'
const NotificationContext = createContext(null)
export function useNotifications() {
return useContext(NotificationContext)
}
// ─── Persistent banner (one message at a time, stacked) ───────────────────────
function NotificationBanner({ message, onAck }) {
const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
background: '#1e1b4b', border: '1px solid #6366f1',
borderRadius: 14, padding: '12px 14px',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'slideIn 0.25s ease',
}}>
<span style={{ fontSize: 22, flexShrink: 0 }}>📢</span>
<div style={{ flex: 1, minWidth: 0 }}>
{message.sender_name && (
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{message.sender_name}
</div>
)}
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
{message.body}
</div>
{tableIds.length > 0 && (
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
</div>
)}
</div>
<button
onClick={() => onAck(message.id)}
style={{
flexShrink: 0, height: 32, padding: '0 12px',
borderRadius: 8, border: 'none',
background: '#4f46e5', color: 'white',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}
>OK </button>
</div>
)
}
export function NotificationProvider({ children }) {
const { token, user } = useAuthStore()
const [pendingMessages, setPendingMessages] = useState([]) // unacked
const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history)
const pollRef = useRef(null)
const fetchUnread = useCallback(async () => {
if (!token || !user) return
try {
const res = await client.get('/api/messages/unread')
setPendingMessages(res.data)
} catch { /* offline or unauthenticated — swallow */ }
}, [token, user?.id])
const fetchRecent = useCallback(async () => {
if (!token || !user) return
try {
const res = await client.get('/api/messages/recent?limit=10')
setRecentMessages(res.data)
} catch { }
}, [token, user?.id])
useEffect(() => {
if (!token || !user) return
fetchUnread()
fetchRecent()
pollRef.current = setInterval(fetchUnread, 2000)
return () => clearInterval(pollRef.current)
}, [token, user?.id])
async function ackMessage(messageId) {
try {
await client.post(`/api/messages/${messageId}/ack`)
setPendingMessages(prev => prev.filter(m => m.id !== messageId))
fetchRecent()
} catch { }
}
const unreadCount = pendingMessages.length
return (
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}>
{children}
{/* Floating banner stack (max 3 visible) */}
{pendingMessages.length > 0 && (
<div style={{
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
padding: '0 12px',
display: 'flex', flexDirection: 'column', gap: 8,
pointerEvents: 'none',
}}>
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
{pendingMessages.slice(0, 3).map(msg => (
<div key={msg.id} style={{ pointerEvents: 'all' }}>
<NotificationBanner message={msg} onAck={ackMessage} />
</div>
))}
{pendingMessages.length > 3 && (
<div style={{
textAlign: 'center', fontSize: 12, color: '#94a3b8',
pointerEvents: 'all',
}}>
+{pendingMessages.length - 3} ακόμα μηνύματα
</div>
)}
</div>
)}
</NotificationContext.Provider>
)
}

View File

@@ -1,32 +1,101 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* Prevent text selection everywhere — app behaves like native */
*, *::before, *::after {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
input, textarea, [contenteditable] {
-webkit-user-select: text;
user-select: text;
}
@keyframes tab-pulse {
0% { opacity: 1; }
50% { opacity: 0.25; }
100% { opacity: 1; }
}
@keyframes gate-spin {
to { transform: rotate(360deg); }
}
:root {
--bg: #0f172a;
--bg2: #1e293b;
--bg3: #334155;
--text: #e2e8f0;
--muted: #64748b;
--accent: #f59e0b;
--accent-dim: #78350f;
--success: #22c55e;
--danger: #ef4444;
--danger-dim: #7f1d1d;
--border: #334155;
/* "Free" table card — dark theme: muted blue-slate */
--card-free-bg: #243044;
--card-free-text: #94b8d4;
--card-free-muted: rgba(148,184,212,0.45);
/* Dark theme — deep navy */
--bg: #0d1520;
--bg2: #1a2535;
--bg3: #243044;
--bg4: #2e3d54;
--text: #edf2f7;
--text2: #94a3b8;
--muted: #5a7390;
--accent: #f59e0b;
--accent-fg: #1c1000;
--accent-dim: #6b3a00;
--success: #22c55e;
--success-fg: #052e16;
--danger: #f87171;
--danger-sat: #ef4444;
--danger-dim: #450a0a;
--primary: #3b82f6;
--primary-fg: #ffffff;
--border: #253245;
--shadow: rgba(0,0,0,0.35);
font-family: system-ui, 'Segoe UI', sans-serif;
font-size: 16px;
color: var(--text);
background: var(--bg);
}
body { background: var(--bg); }
[data-theme="light"] {
/* "Free" table card — light theme: cool light grey */
--card-free-bg: #dde5ef;
--card-free-text: #3d5270;
--card-free-muted: rgba(61,82,112,0.45);
#root { min-height: 100svh; display: flex; flex-direction: column; }
/* Light theme — warm slate / off-white */
--bg: #f1f5f9;
--bg2: #ffffff;
--bg3: #e8edf4;
--bg4: #dce3ee;
--text: #1e293b;
--text2: #475569;
--muted: #7a8fa6;
--accent: #e08c00;
--accent-fg: #ffffff;
--accent-dim: #fef3c7;
--success: #16a34a;
--success-fg: #ffffff;
--danger: #dc2626;
--danger-sat: #dc2626;
--danger-dim: #fee2e2;
--primary: #2563eb;
--primary-fg: #ffffff;
--border: #cdd6e0;
--shadow: rgba(0,0,0,0.10);
}
html, body {
background: var(--bg);
overscroll-behavior: none;
overflow: hidden;
height: 100%;
}
#root { height: 100%; display: flex; flex-direction: column; }
/* ── Layout ─────────────────────────────────────────────── */
.page {
display: flex;
flex-direction: column;
min-height: 100svh;
height: 100svh;
overflow: hidden;
background: var(--bg);
}
.page--centered {
@@ -66,12 +135,12 @@ body { background: var(--bg); }
min-height: 48px;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary { background: #1d4ed8; color: #fff; }
.btn--accent { background: var(--accent); color: #1c1400; }
.btn--success { background: #15803d; color: #fff; }
.btn--danger { background: var(--danger); color: #fff; }
.btn--secondary{ background: var(--bg3); color: var(--text); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn--primary { background: var(--primary); color: var(--primary-fg); }
.btn--accent { background: var(--accent); color: var(--accent-fg); }
.btn--success { background: var(--success); color: var(--success-fg); }
.btn--danger { background: var(--danger-sat); color: #fff; }
.btn--secondary{ background: var(--bg3); color: var(--text); }
.btn--lg { min-height: 64px; font-size: 17px; border-radius: 14px; }
.icon-btn {
@@ -113,7 +182,7 @@ body { background: var(--bg); }
}
.pin-btn:active { background: var(--bg3); }
.pin-btn--secondary { background: transparent; color: var(--muted); }
.pin-btn--confirm { background: var(--accent); color: #1c1400; border-color: var(--accent); }
.pin-btn--confirm { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.pin-btn--confirm:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Login ───────────────────────────────────────────────── */
@@ -127,7 +196,7 @@ body { background: var(--bg); }
padding: 32px 24px;
}
.app-title { font-size: 32px; font-weight: 700; color: var(--accent); }
.app-subtitle { font-size: 14px; color: var(--muted); margin-top: -16px; }
.app-subtitle { font-size: 14px; color: var(--muted); }
.login-greeting { font-size: 16px; color: var(--text); }
.text-input {
width: 100%;
@@ -140,7 +209,7 @@ body { background: var(--bg); }
outline: none;
}
.text-input:focus { border-color: var(--accent); }
.error-msg { color: #fca5a5; font-size: 14px; text-align: center; }
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
/* ── Filter Tabs ─────────────────────────────────────────── */
.filter-tabs {
@@ -161,35 +230,34 @@ body { background: var(--bg); }
font-weight: 600;
cursor: pointer;
}
.filter-tab--active { background: var(--accent); color: #1c1400; }
.filter-tab--active { background: var(--accent); color: var(--accent-fg); }
/* ── Table Grid ──────────────────────────────────────────── */
.table-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
align-content: start;
}
.table-card {
.table-card-v2 {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 132px;
max-height: 132px;
border-radius: 14px;
border: 2px solid transparent;
align-items: flex-start;
justify-content: flex-start;
padding: 12px 12px 48px;
width: 100%;
min-height: 116px;
border-radius: 16px;
border: none;
cursor: pointer;
font-size: 14px;
text-align: left;
overflow: hidden;
transition: transform 0.12s;
box-shadow: 0 2px 10px var(--shadow);
}
.table-card__number { font-size: 28px; font-weight: 700; }
.table-card__name { font-size: 12px; color: var(--muted); }
.table-card__status { font-size: 12px; font-weight: 600; margin-top: 2px; }
.table-card--free { background: var(--bg2); color: var(--muted); border-color: var(--border); }
.table-card--active { background: #1e3a5f; color: #93c5fd; border-color: #1d4ed8; }
.table-card--mine { background: #451a03; color: var(--accent); border-color: var(--accent); }
.table-card-v2:active { transform: scale(0.96); }
/* ── FAB ─────────────────────────────────────────────────── */
.fab {
@@ -200,11 +268,11 @@ body { background: var(--bg); }
height: 56px;
border-radius: 50%;
background: var(--accent);
color: #1c1400;
color: var(--accent-fg);
font-size: 24px;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
box-shadow: 0 4px 16px var(--shadow);
}
/* ── Cart badge ──────────────────────────────────────────── */
@@ -213,7 +281,7 @@ body { background: var(--bg); }
top: 2px;
right: 2px;
background: var(--accent);
color: #1c1400;
color: var(--accent-fg);
font-size: 10px;
font-weight: 700;
border-radius: 50%;
@@ -227,15 +295,46 @@ body { background: var(--bg); }
/* ── Category Tabs ───────────────────────────────────────── */
.category-tabs {
display: flex;
gap: 8px;
padding: 10px 12px;
overflow-x: auto;
align-items: center;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.category-tabs__sticky {
flex-shrink: 0;
padding: 10px 0 10px 12px;
display: flex;
align-items: center;
background: var(--bg2);
z-index: 2;
}
.category-tabs__scroll-wrap {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: stretch;
overflow: hidden;
}
.category-tabs__fade {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
background: linear-gradient(to right, var(--bg2) 40%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.category-tabs__scroll {
display: flex;
gap: 8px;
padding: 10px 12px 10px 36px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex: 1;
}
.category-tabs::-webkit-scrollbar { display: none; }
.category-tabs__scroll::-webkit-scrollbar { display: none; }
.cat-tab {
flex-shrink: 0;
padding: 8px 16px;
@@ -249,13 +348,15 @@ body { background: var(--bg); }
white-space: nowrap;
transition: filter 0.12s;
}
.cat-tab--active { background: var(--accent); color: #1c1400; }
.cat-tab--active { background: var(--accent); color: var(--accent-fg); }
.cat-tab--viewall {
background: var(--bg3);
color: var(--text);
font-size: 18px;
padding: 4px 12px;
padding: 8px 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Category All Modal ──────────────────────────────────── */
@@ -283,23 +384,35 @@ body { background: var(--bg); }
}
.cat-all-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px;
overflow-y: auto;
flex: 1;
align-content: start;
}
@media (min-width: 480px) {
.cat-all-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 720px) {
.cat-all-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.cat-all-tile {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
height: 76px;
max-height: 76px;
border-radius: 14px;
border: none;
cursor: pointer;
overflow: hidden;
padding: 8px;
padding: 8px 10px;
}
.cat-all-tile--active { outline: 3px solid #fff; }
.cat-all-tile__overlay {
@@ -309,38 +422,135 @@ body { background: var(--bg); }
}
.cat-all-tile__name {
position: relative;
font-size: 14px;
font-size: 16px;
font-weight: 700;
color: #fff;
text-align: center;
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
/* ── Product Grid ────────────────────────────────────────── */
.product-picker { display: flex; flex-direction: column; flex: 1; }
.product-picker { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.product-area { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
/* Sub-category accordion */
.subcat-accordion { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; }
.subcat-section { border-radius: 12px; overflow: hidden; background: var(--bg2); border: 1px solid var(--border); }
.subcat-header {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--text);
transition: background 0.12s;
}
.subcat-header:active { background: var(--bg3); }
.subcat-header--open { background: var(--bg3); }
.subcat-header__pill {
width: 4px;
height: 28px;
border-radius: 4px;
flex-shrink: 0;
opacity: 0.85;
}
.subcat-header__name { flex: 1; font-size: 14px; font-weight: 600; }
.subcat-header__count {
font-size: 11px;
font-weight: 700;
color: var(--muted);
background: var(--bg3);
border-radius: 10px;
padding: 2px 7px;
flex-shrink: 0;
}
.subcat-header--open .subcat-header__count { background: var(--bg); }
.subcat-header__chevron { flex-shrink: 0; color: var(--muted); transition: transform 200ms ease; }
.subcat-body { padding: 0 0 6px; }
.subcat-body .product-grid { padding: 8px 10px; overflow-y: unset; }
.subcat-general .product-grid { padding: 8px 10px; }
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
padding: 12px;
overflow-y: auto;
}
.product-btn {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
flex-direction: row;
align-items: stretch;
gap: 0;
padding: 0;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
text-align: left;
min-height: 80px;
overflow: hidden;
}
.product-btn:active { background: var(--bg3); }
.product-btn__name { font-size: 14px; font-weight: 600; color: var(--text); line-height: 1.3; }
.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: auto; }
.product-btn__thumb {
flex-shrink: 0;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.product-btn__thumb-inner {
width: 64px;
height: 64px;
border-radius: 10px;
overflow: hidden;
background: var(--bg3);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.product-btn__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-btn__initials {
font-size: 24px;
font-weight: 700;
color: var(--muted);
user-select: none;
}
.product-btn__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 10px 12px;
}
.product-btn__name {
font-size: 13px;
font-weight: 600;
color: var(--text);
line-height: 1.35;
/* always occupy exactly 2 lines */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: calc(1.35em * 2);
}
.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 4px; }
/* ── Cart Panel ──────────────────────────────────────────── */
.cart-panel {
@@ -364,15 +574,16 @@ body { background: var(--bg); }
/* ── Order Summary ───────────────────────────────────────── */
.order-summary { display: flex; flex-direction: column; gap: 0; overflow-y: auto; flex: 1; padding: 12px; }
.order-item { padding: 12px 0; border-bottom: 1px solid var(--border); }
.order-item { padding: 12px 10px; border-bottom: 1px solid var(--border); }
.order-item--last { border-bottom: none; }
.order-item--paid { opacity: 0.5; }
.order-item--cancelled { opacity: 0.3; text-decoration: line-through; }
.order-item--selected { background: rgba(245,158,11,0.08); border-radius: 8px; padding: 8px; }
.order-item--selected { background: rgba(245,158,11,0.10); border-radius: 8px; }
.order-item__row { display: flex; align-items: center; gap: 8px; }
.order-item__name { flex: 1; font-size: 15px; font-weight: 600; }
.order-item__qty { font-size: 13px; color: var(--muted); }
.order-item__price { font-size: 14px; color: var(--text); font-weight: 600; }
.order-item__modifier { font-size: 12px; color: var(--muted); padding-left: 16px; margin-top: 2px; }
.order-item__name { flex: 1; font-size: 17px; font-weight: 600; }
.order-item__qty { font-size: 15px; color: var(--muted); }
.order-item__price { font-size: 16px; color: var(--text); font-weight: 600; }
.order-item__modifier { font-size: 13px; color: var(--muted); padding-left: 16px; margin-top: 3px; }
.order-summary__total {
display: flex;
justify-content: space-between;
@@ -384,11 +595,12 @@ body { background: var(--bg); }
margin-top: 8px;
}
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px; margin-left: 4px; }
.badge--paid { background: #15803d; color: #fff; }
.badge--cancelled { background: var(--danger-dim); color: #fca5a5; }
.badge--paid { background: var(--success); color: var(--success-fg); }
.badge--cancelled{ background: var(--danger-dim); color: var(--danger); }
.badge--draft { background: var(--accent-dim); color: var(--accent); }
/* ── Detail Body ─────────────────────────────────────────── */
.detail-body { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.detail-body { display: flex; flex-direction: column; flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
.action-bar {
display: flex;
gap: 8px;
@@ -407,6 +619,9 @@ body { background: var(--bg); }
align-items: flex-end;
z-index: 100;
}
.modal-overlay--top {
align-items: flex-start;
}
.modal-sheet {
background: var(--bg2);
border-radius: 20px 20px 0 0;
@@ -418,6 +633,10 @@ body { background: var(--bg); }
flex-direction: column;
gap: 12px;
}
.modal-sheet--top {
border-radius: 0 0 20px 20px;
padding: 12px 20px 24px;
}
.modal-handle {
width: 40px;
height: 4px;
@@ -425,6 +644,10 @@ body { background: var(--bg); }
border-radius: 2px;
margin: 0 auto 8px;
}
.modal-sheet--top .modal-handle {
margin: 8px auto 0;
order: 99;
}
.modal-title { font-size: 20px; font-weight: 700; text-align: center; }
.modal-price { font-size: 18px; color: var(--accent); text-align: center; font-weight: 600; }
.modal-section h3 { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
@@ -466,3 +689,44 @@ body { background: var(--bg); }
cursor: pointer;
}
.qty-value { font-size: 24px; font-weight: 700; min-width: 36px; text-align: center; }
/* ── User Menu Dropdown ──────────────────────────────────── */
.user-menu-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 200;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
min-width: 200px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--text);
font-size: 15px;
cursor: pointer;
text-align: left;
}
.user-menu-item:hover { background: var(--bg3); }
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
.user-menu-item--disabled:hover { background: transparent; }
.user-menu-item--danger { color: var(--danger); }
.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
.user-menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}

View File

@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import ProductPicker from '../components/ProductPicker'
import OrderDrawer from '../components/OrderDrawer'
import client from '../api/client'
export default function AddItemsPage() {
const { tableId } = useParams()
const [searchParams] = useSearchParams()
const isNewTable = searchParams.get('new') === '1'
const navigate = useNavigate()
const [categories, setCategories] = useState([])
@@ -12,9 +15,11 @@ export default function AddItemsPage() {
const [cart, setCart] = useState([])
const [orderId, setOrderId] = useState(null)
const [sending, setSending] = useState(false)
const [retrying, setRetrying] = useState(false)
const [error, setError] = useState('')
// null = not yet sent, { allOk, results } = sent
const [printAck, setPrintAck] = useState(null)
const [cartOpen, setCartOpen] = useState(false)
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
useEffect(() => {
async function load() {
@@ -30,31 +35,81 @@ export default function AddItemsPage() {
load()
}, [tableId])
// Back button: if this was a new table and nothing was added, leave the table FREE
function handleBack() {
if (isNewTable && cart.length === 0) {
navigate('/tables', { replace: true })
} else {
navigate(`/tables/${tableId}`)
}
}
function addToCart(item) {
setCart(prev => [...prev, { ...item, _key: Date.now() + Math.random() }])
setCart(prev => {
// Try to find an identical item already in the cart to stack onto.
// Two items are identical when every meaningful field matches exactly.
const { _key: _k, _drawerState: _ds, ...newCore } = item
const matchIdx = prev.findIndex(existing => {
const { _key, _drawerState, ...existCore } = existing
return JSON.stringify(existCore) === JSON.stringify(newCore)
})
if (matchIdx !== -1) {
const next = [...prev]
next[matchIdx] = { ...next[matchIdx], quantity: next[matchIdx].quantity + (item.quantity ?? 1) }
return next
}
return [...prev, { ...item, _key: Date.now() + Math.random() }]
})
}
function removeFromCart(key) {
setCart(prev => prev.filter(i => i._key !== key))
}
function changeCartQty(key, newQty) {
if (newQty <= 0) {
removeFromCart(key)
} else {
setCart(prev => prev.map(i => i._key === key ? { ...i, quantity: newQty } : i))
}
}
function openEditDrawer(cartItem) {
const product = products.find(p => p.id === cartItem.product_id)
if (!product) return
setCartOpen(false)
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
}
function handleEditSave(updatedItem) {
setCart(prev => prev.map(i =>
i._key === editItem.cartKey ? { ...updatedItem, _key: i._key } : i
))
setEditItem(null)
}
async function sendOrder() {
if (cart.length === 0 || !orderId) return
if (cart.length === 0) return
setSending(true)
setError('')
setPrintAck(null)
setCartOpen(false)
try {
const res = await client.post(`/api/orders/${orderId}/items`, {
items: cart.map(({ _key, ...item }) => item),
// For new (free) tables, open the order now — lazily
let activeOrderId = orderId
if (!activeOrderId) {
const { data: newOrder } = await client.post('/api/orders/', { table_id: Number(tableId) })
activeOrderId = newOrder.id
setOrderId(activeOrderId)
}
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
items: cart.map(({ _key, _drawerState, ...item }) => item),
})
const printResults = res.data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
setPrintAck({ allOk, results: printResults })
if (allOk) {
// All printed fine — navigate back after a short moment
setTimeout(() => navigate(`/tables/${tableId}`), 1200)
}
// If there were print failures, stay on page — waiter sees the ack panel
if (allOk) setTimeout(() => navigate('/tables'), 1200)
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
} finally {
@@ -62,135 +117,454 @@ export default function AddItemsPage() {
}
}
function getProductName(id) {
return products.find(p => p.id === id)?.name || `#${id}`
async function retryNow() {
if (!orderId) return
setRetrying(true)
try {
const res = await client.post(`/api/orders/${orderId}/retry-print`)
const printResults = res.data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
setPrintAck({ allOk, results: printResults })
if (allOk) setTimeout(() => navigate('/tables'), 1200)
} catch { } finally { setRetrying(false) }
}
// If we have a print ack with failures, show the ack overlay instead of the normal UI
function saveAsDraft() { navigate(`/tables/${tableId}`, { replace: true }) }
function leaveAndContinue() { navigate(`/tables/${tableId}`, { replace: true }) }
function getProduct(id) { return products.find(p => p.id === id) }
// Returns structured sections for the expanded cart view
function buildItemSections(item, product) {
const sections = []
if (item.selected_options?.length) {
// Group consecutive options into logical sections by type
// Prefs: options that match a preference choice (have a real id matching preference_sets choices)
const prefIds = new Set(
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
)
const quickNames = new Set((product?.quick_options || []).map(o => o.name))
const extraIds = new Set((product?.options || []).map(o => o.id))
const prefLines = []
const quickLines = []
const extraLines = []
item.selected_options.forEach(o => {
if (prefIds.has(o.id)) prefLines.push(o)
else if (o.id != null && extraIds.has(o.id)) extraLines.push(o)
else if (quickNames.has(o.name)) quickLines.push(o)
else if (o.id == null) {
// sub-choice — attach to last extra or pref line
if (extraLines.length > 0) extraLines.push({ ...o, _sub: true })
else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true })
}
})
// Deduplicate quick lines: multiple entries of same name → single entry with qty
const quickDeduped = []
quickLines.forEach(o => {
const existing = quickDeduped.find(x => x.name === o.name)
if (existing) existing._qty = (existing._qty || 1) + 1
else quickDeduped.push({ ...o, _qty: 1 })
})
if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines })
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines })
}
if (item.removed_ingredients?.length) {
sections.push({ type: 'removed', lines: item.removed_ingredients.map(n => ({ name: n })) })
}
if (item.notes) {
sections.push({ type: 'note', lines: [{ name: item.notes }] })
}
return sections
}
// Simple flat summary for the collapsed one-liner
function buildItemSummary(item) {
const lines = []
if (item.selected_options?.length) {
item.selected_options.forEach(o => {
if (o.price_delta && o.price_delta !== 0)
lines.push(`${o.name} (${o.price_delta > 0 ? '+' : ''}${o.price_delta.toFixed(2)} €)`)
else lines.push(o.name)
})
}
if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`)
if (item.notes) lines.push(item.notes)
return lines
}
// Print-failure dialog
if (printAck && !printAck.allOk) {
return (
<div className="page">
<header className="top-bar">
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}></button>
<span className="top-bar__title">Αποτέλεσμα εκτύπωσης</span>
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`, { replace: true })}></button>
<span className="top-bar__title">Πρόβλημα εκτύπωσης</span>
</header>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: 20 }}>
<div style={{
background: '#7f1d1d', borderRadius: 14, padding: '16px 18px',
border: '1px solid #ef4444',
}}>
<p style={{ fontWeight: 700, fontSize: 16, color: '#fca5a5', marginBottom: 8 }}>
Πρόβλημα εκτύπωσης
</p>
<p style={{ fontSize: 14, color: '#fca5a5' }}>
Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
Τα αντικείμενα παραμένουν ως "σχέδιο" δεν έχουν σταλεί στην κουζίνα/μπαρ.
</p>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, padding: 20, overflowY: 'auto' }}>
<div style={{ background: '#7f1d1d', borderRadius: 14, padding: '14px 16px', border: '1px solid #ef4444' }}>
<p style={{ fontWeight: 700, fontSize: 15, color: '#fca5a5', marginBottom: 6 }}> Η παραγγελία αποθηκεύτηκε</p>
<p style={{ fontSize: 13, color: '#fca5a5', lineHeight: 1.5 }}>Ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.</p>
</div>
{printAck.results.map((r, i) => (
<div
key={i}
style={{
display: 'flex', alignItems: 'center', gap: 12,
background: r.success ? '#14532d' : '#7f1d1d',
border: `1px solid ${r.success ? '#22c55e' : '#ef4444'}`,
borderRadius: 12, padding: '12px 16px',
}}
>
<span style={{ fontSize: 22 }}>{r.success ? '✓' : '✗'}</span>
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, background: r.success ? '#14532d' : '#431407', border: `1px solid ${r.success ? '#22c55e' : '#c2410c'}`, borderRadius: 12, padding: '10px 14px' }}>
<span style={{ fontSize: 20 }}>{r.success ? '✓' : '✗'}</span>
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 600, fontSize: 15, color: r.success ? '#86efac' : '#fca5a5' }}>
{r.printer_name}
</p>
{r.error && (
<p style={{ fontSize: 12, color: '#fca5a5', marginTop: 2 }}>{r.error}</p>
)}
<p style={{ fontWeight: 600, fontSize: 14, color: r.success ? '#86efac' : '#fdba74' }}>{r.printer_name}</p>
{!r.success && <p style={{ fontSize: 12, color: '#fdba74', marginTop: 2 }}>Εκτυπωτής μη προσβάσιμος</p>}
</div>
</div>
))}
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button
className="btn btn--secondary"
style={{ flex: 1 }}
onClick={() => navigate(`/tables/${tableId}`)}
>
Επιστροφή στο τραπέζι
</button>
<button
className="btn btn--primary"
style={{ flex: 1 }}
onClick={async () => {
setPrintAck(null)
setCart([])
navigate(`/tables/${tableId}`)
}}
>
Εντάξει, συνέχεια
</button>
</div>
<p style={{ fontSize: 12, color: '#64748b', textAlign: 'center', margin: '4px 0' }}>Επιλέξτε πώς να συνεχίσετε:</p>
<button className="btn btn--primary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, opacity: retrying ? 0.7 : 1 }} onClick={retryNow} disabled={retrying}>
<span style={{ fontSize: 22 }}>🔄</span>
<div>
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>{retrying ? 'Επανάληψη…' : 'Επανάληψη τώρα'}</p>
<p style={{ fontSize: 12, opacity: 0.8, margin: 0, marginTop: 2 }}>Δοκιμή αποστολής στον εκτυπωτή ξανά</p>
</div>
</button>
<button className="btn btn--secondary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }} onClick={saveAsDraft}>
<span style={{ fontSize: 22 }}>📋</span>
<div>
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Αποθήκευση ως προσχέδιο</p>
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα μένουν στο τραπέζι με πορτοκαλί ένδειξη</p>
</div>
</button>
<button style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, background: '#1e293b', border: '1px solid #334155', borderRadius: 12, color: '#cbd5e1', cursor: 'pointer' }} onClick={leaveAndContinue}>
<span style={{ fontSize: 22 }}>🕐</span>
<div>
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Συνέχεια (προσχέδιο)</p>
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα εμφανίζονται ως εκκρεμή στο dashboard</p>
</div>
</button>
</div>
</div>
)
}
// Compact names for the strip preview (max 3 items shown)
const stripItems = cart.slice(-3).reverse()
const hiddenCount = cart.length > 3 ? cart.length - 3 : 0
return (
<div className="page">
<div className="page" style={{ position: 'relative' }}>
<header className="top-bar">
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}></button>
<span className="top-bar__title">Προσθήκη</span>
<button className="icon-btn" onClick={handleBack}></button>
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
{/* Cart icon with badge — opens side drawer */}
<button
className="icon-btn"
style={{ position: 'relative' }}
onClick={() => document.getElementById('cart-panel').scrollIntoView({ behavior: 'smooth' })}
onClick={() => setCartOpen(true)}
>
🛒
{cart.length > 0 && <span className="cart-badge">{cart.length}</span>}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{cart.length > 0 && (
<span style={{
position: 'absolute', top: -2, right: -2,
minWidth: 18, height: 18, borderRadius: 9,
background: 'var(--accent)', color: 'var(--accent-fg)',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 4px',
}}>{cart.length}</span>
)}
</button>
</header>
{/* Product picker takes all remaining space */}
{categories.length > 0 && (
<ProductPicker categories={categories} products={products} onAdd={addToCart} />
)}
<div id="cart-panel" className="cart-panel">
<h3 className="cart-panel__title">Σταδιακά ({cart.length})</h3>
{cart.length === 0 && (
<p style={{ color: '#64748b', textAlign: 'center' }}>Προσθέστε αντικείμενα</p>
)}
{cart.map(item => (
<div key={item._key} className="cart-row">
<span>{getProductName(item.product_id)} ×{item.quantity}</span>
<button className="icon-btn icon-btn--danger" onClick={() => removeFromCart(item._key)}></button>
</div>
))}
{error && <p className="error-msg">{error}</p>}
{/* Success flash when all printers OK */}
{printAck?.allOk && (
<div style={{
background: '#14532d', border: '1px solid #22c55e',
borderRadius: 10, padding: '10px 14px',
color: '#86efac', fontWeight: 600, fontSize: 14, textAlign: 'center',
}}>
Εκτυπώθηκε επιτυχώς μεταφορά
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
<div style={{
background: 'var(--bg2)',
borderTop: '1px solid var(--border)',
padding: '10px 12px 14px',
flexShrink: 0,
}}>
{/* Floating compact cart — shown only when there are items */}
{cart.length > 0 && (
<div
onClick={() => setCartOpen(true)}
style={{
background: 'var(--bg3)',
border: '1px solid var(--border)',
borderRadius: 12,
padding: '8px 12px',
marginBottom: 10,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{stripItems.map(item => {
const p = getProduct(item.product_id)
return (
<div key={item._key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{p?.name ?? `#${item.product_id}`}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
</div>
)
})}
{hiddenCount > 0 && (
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
+{hiddenCount} ακόμα δείτε όλα
</div>
)}
</div>
)}
{/* Full-width send button */}
<button
className="btn btn--primary btn--lg"
style={{ width: '100%', marginTop: 16 }}
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
onClick={sendOrder}
disabled={cart.length === 0 || sending}
>
{sending ? 'Αποστολή…' : 'Αποστολή Παραγγελίας'}
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
</button>
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
{printAck?.allOk && (
<div style={{ marginTop: 8, background: '#14532d', border: '1px solid #22c55e', borderRadius: 10, padding: '8px 14px', color: '#86efac', fontWeight: 600, fontSize: 13, textAlign: 'center' }}>
Εκτυπώθηκε επιτυχώς μεταφορά
</div>
)}
</div>
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
<>
{/* Backdrop */}
<div
onClick={() => setCartOpen(false)}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.55)',
opacity: cartOpen ? 1 : 0,
pointerEvents: cartOpen ? 'auto' : 'none',
transition: 'opacity 240ms ease',
zIndex: 50,
}}
/>
{/* Panel */}
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0,
width: 'min(88vw, 380px)',
background: 'var(--bg)',
borderLeft: '1px solid var(--border)',
transform: cartOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 280ms cubic-bezier(0.32, 0.72, 0, 1)',
zIndex: 51,
display: 'flex', flexDirection: 'column',
boxShadow: '-8px 0 32px rgba(0,0,0,0.4)',
}}>
{/* Drawer header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Παραγγελία</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'}</div>
</div>
<button onClick={() => setCartOpen(false)} style={{ background: 'var(--bg3)', border: 'none', borderRadius: '50%', width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Item list */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{cart.length === 0 ? (
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '40px 0', fontSize: 14 }}>Η παραγγελία είναι κενή.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{cart.map(item => {
const product = getProduct(item.product_id)
const summaryLines = buildItemSummary(item)
const sections = buildItemSections(item, product)
return (
<CartItem
key={item._key}
item={item}
product={product}
summaryLines={summaryLines}
sections={sections}
onEdit={() => openEditDrawer(item)}
onRemove={() => removeFromCart(item._key)}
onChangeQty={qty => changeCartQty(item._key, qty)}
/>
)
})}
</div>
)}
</div>
{/* Drawer footer */}
<div style={{ padding: '12px 12px 20px', borderTop: '1px solid var(--border)', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
className="btn btn--primary btn--lg"
style={{ width: '100%' }}
onClick={sendOrder}
disabled={cart.length === 0 || sending}
>
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
</button>
</div>
</div>
</>
{/* Edit drawer */}
{editItem && (
<OrderDrawer
product={editItem.product}
isOpen={!!editItem}
onClose={() => setEditItem(null)}
onAdd={handleEditSave}
initialState={editItem.drawerState}
/>
)}
</div>
)
}
// ── Cart Item (used in the side drawer) ───────────────────────────────────────
const SECTION_META = {
prefs: { icon: '◉', label: null },
quick: { icon: '>', label: null },
extras: { icon: '+', label: null },
removed: { icon: '', label: null },
note: { icon: 'i', label: null },
}
function SectionIcon({ type }) {
const icons = {
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
}
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
}
function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onChangeQty }) {
const [expanded, setExpanded] = useState(false)
const hasDetails = sections.length > 0
return (
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
{/* Whole header row is always clickable to expand (qty stepper is always available) */}
<div
onClick={() => setExpanded(e => !e)}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', cursor: 'pointer' }}
>
{/* Chevron — always shown */}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms', flexShrink: 0, color: 'var(--muted)' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
{/* Name */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{product?.name ?? `#${item.product_id}`}
</div>
{!expanded && hasDetails && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{summaryLines[0]}{summaryLines.length > 1 ? ` +${summaryLines.length - 1}` : ''}
</div>
)}
</div>
{/* Quantity on the right */}
<span style={{ color: '#f59e0b', fontSize: 13, fontWeight: 700, fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
{/* Edit — stop propagation so it doesn't toggle expand */}
<button onClick={e => { e.stopPropagation(); onEdit() }} style={{ background: 'none', border: '1px solid var(--border)', borderRadius: 7, color: 'var(--muted)', cursor: 'pointer', padding: '3px 9px', fontSize: 12, fontWeight: 500, flexShrink: 0 }}>
Επεξ.
</button>
{/* Remove */}
<button onClick={e => { e.stopPropagation(); onRemove() }} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{expanded && (
<div style={{ paddingBottom: 10 }}>
{sections.map((sec, si) => (
<div key={si}>
{/* Divider between sections */}
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<SectionIcon type={line._sub ? 'quick' : sec.type} />
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1 }}>
{sec.type === 'note' ? line.name : (
<>
{line.name}
{line._qty > 1 && (
<span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>
)}
{line.price_delta !== 0 && line.price_delta != null && (
<span style={{ color: 'var(--muted)', marginLeft: 4 }}>
({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} )
</span>
)}
</>
)}
</span>
</div>
))}
</div>
</div>
))}
{/* ── Quick qty row ── */}
<div style={{ margin: '8px 12px 0', height: 1, background: 'var(--border)' }} />
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
padding: '10px 12px 2px',
}}>
<button
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity - 1) }}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--bg3)', border: '1px solid var(--border)',
color: item.quantity <= 1 ? 'var(--muted)' : 'var(--danger)',
fontSize: 20, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{item.quantity <= 1 ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
) : ''}
</button>
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', minWidth: 28, textAlign: 'center' }}>
{item.quantity}
</span>
<button
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity + 1) }}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--bg3)', border: '1px solid var(--border)',
color: '#22c55e',
fontSize: 20, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,63 +1,200 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import PinPad from '../components/PinPad'
import useAuthStore from '../store/authStore'
import client from '../api/client'
const API_URL = import.meta.env.VITE_API_URL || ''
// ─── Waiter card ──────────────────────────────────────────────────────────────
function WaiterCard({ waiter, onClick }) {
const initials = (waiter.full_name || waiter.nickname || '?')
.split(' ')
.map(w => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px',
background: 'var(--bg2)',
border: '1px solid var(--border)',
borderRadius: 14,
cursor: 'pointer',
width: '100%',
textAlign: 'left',
position: 'relative',
transition: 'border-color 0.15s',
boxSizing: 'border-box',
}}
>
{/* Avatar */}
<div style={{
width: 48, height: 48, borderRadius: '50%', flexShrink: 0,
background: waiter.avatar_url ? 'transparent' : 'var(--bg3)',
border: `2px solid ${waiter.on_shift ? '#22c55e' : 'var(--border)'}`,
overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 700, color: 'var(--text)',
}}>
{waiter.avatar_url
? <img src={`${API_URL}${waiter.avatar_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: initials
}
</div>
{/* Name block */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', wordBreak: 'break-word', whiteSpace: 'normal', lineHeight: 1.3 }}>
{waiter.full_name || waiter.nickname || '—'}
</div>
{waiter.nickname && waiter.full_name && (
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{waiter.nickname}</div>
)}
</div>
{/* On-shift dot */}
{waiter.on_shift && (
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: '#22c55e',
flexShrink: 0,
boxShadow: '0 0 6px #22c55e88',
}} title="Σε βάρδια" />
)}
<span style={{ color: 'var(--muted)', fontSize: 18, flexShrink: 0 }}></span>
</button>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function LoginPage() {
const { savedUsername, login, clearSavedUsername } = useAuthStore()
const [username, setUsername] = useState(savedUsername || '')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuthStore()
const navigate = useNavigate()
const [waiters, setWaiters] = useState([])
const [loadingWaiters, setLoadingWaiters] = useState(true)
const [selectedWaiter, setSelectedWaiter] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
client.get('/api/auth/waiters')
.then(r => setWaiters(r.data))
.catch(() => setWaiters([]))
.finally(() => setLoadingWaiters(false))
}, [])
async function handlePin(pin) {
if (!selectedWaiter) return
setError('')
setLoading(true)
try {
const { data } = await client.post('/api/auth/login', { username, pin })
// We send waiter id as identifier; backend matches by id+pin
const { data } = await client.post('/api/auth/login-by-id', { waiter_id: selectedWaiter.id, pin })
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
setError(err.response?.data?.detail || 'Λανθασμένο PIN')
} finally {
setLoading(false)
}
}
function switchUser() {
clearSavedUsername()
setUsername('')
setError('')
// ── Waiter picker screen ───────────────────────────────────────────────────
if (!selectedWaiter) {
// Sort: on-shift first, then alphabetical
const sorted = [...waiters].sort((a, b) => {
if (a.on_shift !== b.on_shift) return a.on_shift ? -1 : 1
return (a.full_name || a.nickname || '').localeCompare(b.full_name || b.nickname || '')
})
return (
<div className="page">
{/* Static header — never scrolls */}
<div style={{ flexShrink: 0, padding: '40px 16px 20px', textAlign: 'center' }}>
<h1 className="app-title" style={{ marginBottom: 10 }}>TableServe</h1>
<p className="app-subtitle">Ποιος είσαι;</p>
</div>
{/* Scrollable card list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 16px 40px' }}>
<div style={{ maxWidth: 480, margin: '0 auto' }}>
{loadingWaiters ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση</p>
) : waiters.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'stretch' }}>
{sorted.map(w => (
<WaiterCard key={w.id} waiter={w} onClick={() => { setError(''); setSelectedWaiter(w) }} />
))}
</div>
)}
</div>
</div>
</div>
)
}
// ── PIN screen ─────────────────────────────────────────────────────────────
const initials = (selectedWaiter.full_name || selectedWaiter.nickname || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
return (
<div className="page page--centered">
<div className="login-box">
<h1 className="app-title">TableServe</h1>
<p className="app-subtitle">Σύστημα Παραγγελιών</p>
{savedUsername ? (
<p className="login-greeting">Καλωσόρισες, <strong>{savedUsername}</strong></p>
) : (
<input
className="text-input"
placeholder="Όνομα χρήστη"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
/>
)}
{/* Selected waiter mini-card */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 12, padding: '10px 14px', marginBottom: 4,
}}>
<div style={{
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
background: selectedWaiter.avatar_url ? 'transparent' : 'var(--bg3)',
border: `2px solid ${selectedWaiter.on_shift ? '#22c55e' : 'var(--border)'}`,
overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 700, color: 'var(--text)',
}}>
{selectedWaiter.avatar_url
? <img src={`${API_URL}${selectedWaiter.avatar_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: initials
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>
{selectedWaiter.full_name || selectedWaiter.nickname}
</div>
{selectedWaiter.nickname && selectedWaiter.full_name && (
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{selectedWaiter.nickname}</div>
)}
</div>
<button
onClick={() => { setSelectedWaiter(null); setError('') }}
style={{ background: 'none', border: 'none', color: 'var(--muted)', cursor: 'pointer', fontSize: 13, padding: '4px 8px' }}
>
Αλλαγή
</button>
</div>
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, marginBottom: 12 }}>Εισάγετε PIN</p>
<PinPad onSubmit={handlePin} loading={loading} />
{error && <p className="error-msg">{error}</p>}
{savedUsername && (
<button className="link-btn" onClick={switchUser}>
Δεν είσαι εσύ;
</button>
)}
</div>
</div>
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,31 +2,218 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import TableCard from '../components/TableCard'
import ConnectionBanner from '../components/ConnectionBanner'
import UserMenu from '../components/UserMenu'
import useAuthStore from '../store/authStore'
import useTableColourStore from '../store/tableColourStore'
import client from '../api/client'
import { useNotifications } from '../context/NotificationContext'
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
const FILTERS = ['all', 'mine', 'free']
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
// ─── Notification history drawer ─────────────────────────────────────────────
function NotificationDrawer({ messages, onClose, onAck }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
<div className="modal-handle" />
<h2 className="modal-title" style={{ marginBottom: 16 }}>Ειδοποιήσεις</h2>
{messages.length === 0 && (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 24 }}>Δεν υπάρχουν ειδοποιήσεις</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto', flex: 1 }}>
{messages.map(msg => {
const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })()
return (
<div key={msg.id} style={{
padding: '12px 4px', borderBottom: '1px solid var(--border)',
display: 'flex', gap: 12, alignItems: 'flex-start',
opacity: msg._acked ? 0.5 : 1,
}}>
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
<div style={{ flex: 1, minWidth: 0 }}>
{msg.sender_name && (
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
{msg.sender_name}
</div>
)}
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
{tableIds.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>Τραπέζι: {tableIds.join(', ')}</div>
)}
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
{new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
)
})}
</div>
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>Κλείσιμο</button>
</div>
</div>
)
}
// ─── Table quick-view + actions popup (long-press) ────────────────────────────
const QUICK_ACTIONS = [
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
{ Icon: TransferIcon, label: 'Μεταφορά', key: 'transfer', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: MergeIcon, label: 'Συγχώνευση', key: 'merge', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', key: 'print_synopsis', color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
]
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) {
const tableName = table.label || `T${table.number}`
const activeItems = order?.items?.filter(i => i.status === 'active') || []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const paid = order?.payments?.reduce((s, p) => s + p.amount, 0) || 0
const due = Math.max(0, total - paid)
const statusLabel = {
open: 'Ανοιχτό',
partially_paid: 'Μερικώς πληρωμένο',
paid: 'Πληρωμένο',
}[order?.status] || 'Ελεύθερο'
return (
<div className="modal-overlay" onClick={onClose}>
{/* Status overview card */}
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
<div style={{
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
padding: '16px 20px', borderBottom: '1px solid var(--border)',
}}>
<div className="modal-handle" style={{ marginBottom: 12 }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
</div>
{order ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
<span style={{ color: 'var(--muted)' }}>Σύνολο</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtPrice(total)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
<span style={{ color: 'var(--muted)' }}>Πληρωμένο</span>
<span style={{ fontWeight: 600, color: '#22c55e' }}>{fmtPrice(paid)}</span>
</div>
{due > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
<span style={{ color: 'var(--muted)' }}>Υπόλοιπο</span>
<span style={{ fontWeight: 700, color: '#f59e0b' }}>{fmtPrice(due)}</span>
</div>
)}
</div>
) : (
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
)}
{flags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{flags.map(f => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
background: (f.color || '#6295F3') + '22',
border: `1px solid ${f.color || '#6295F3'}`,
borderRadius: 20, padding: '4px 10px',
}}>
<span style={{ fontSize: 14 }}>{f.emoji || '🏷️'}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: f.color || '#6295F3' }}>{f.name}</span>
</div>
))}
</div>
)}
<button
className="btn btn--primary"
style={{ width: '100%', marginTop: 14 }}
onClick={() => { onClose(); onNavigate() }}
>
Άνοιγμα τραπεζιού
</button>
</div>
{/* Quick actions card */}
<div style={{
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
padding: '8px 20px 24px',
borderTop: '2px solid var(--border)',
}}>
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
ACTIONS
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{QUICK_ACTIONS.map((a, i) => {
const disabled = !order && a.key !== 'flags'
return (
<button
key={a.key}
disabled={disabled}
onClick={() => { onClose(); onAction(a.key) }}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0', background: 'none', border: 'none',
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}
>
<span style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: a.color,
}}>
<a.Icon width="18" height="18" />
</span>
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
{!disabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>}
</button>
)
})}
</div>
</div>
</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function TableListPage() {
const { user, logout } = useAuthStore()
const { user } = useAuthStore()
const [tables, setTables] = useState([])
const [groups, setGroups] = useState([])
const [orders, setOrders] = useState([])
const [flagDefs, setFlagDefs] = useState([])
const [flagAssignments, setFlagAssignments] = useState([])
const [filter, setFilter] = useState('all')
const [offline, setOffline] = useState(false)
const [zoneOpen, setZoneOpen] = useState(false)
const [selectedZones, setSelectedZones] = useState(new Set())
const [showNotifs, setShowNotifs] = useState(false)
const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
const zoneRef = useRef(null)
const navigate = useNavigate()
const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {}
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
useEffect(() => {
const handler = () => setOffline(true)
window.addEventListener('backend-offline', handler)
return () => window.removeEventListener('backend-offline', handler)
}, [])
// Close zone dropdown on outside click
useEffect(() => {
function onClick(e) {
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
@@ -37,22 +224,42 @@ export default function TableListPage() {
async function load() {
try {
const [tablesRes, ordersRes, groupsRes] = await Promise.all([
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes] = await Promise.all([
client.get('/api/tables/'),
client.get('/api/orders/my'),
client.get('/api/orders/active'),
client.get('/api/tables/groups'),
client.get('/api/flags/defs'),
client.get('/api/flags/assignments'),
client.get('/api/settings/'),
])
setTables(tablesRes.data)
setOrders(ordersRes.data)
setGroups(groupsRes.data)
setFlagDefs(flagDefsRes.data)
setFlagAssignments(flagAssignRes.data)
const raw = settingsRes.data?.['ui.table_colours']?.value
if (raw) loadFromBackend(raw)
setOffline(false)
} catch {}
}
useEffect(() => { load() }, [])
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
const tableFlagsMap = {}
flagAssignments.forEach(a => {
if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = []
const def = flagDefMap[a.flag_id]
if (def) tableFlagsMap[a.table_id].push(def)
})
function getOrder(tableId) {
return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
return orders.find(o => o.table_id === tableId)
}
function isMyOrder(order) {
if (!order || !user) return false
return order.waiter_ids?.includes(user.id)
}
function toggleZone(id) {
@@ -66,24 +273,50 @@ export default function TableListPage() {
const filtered = tables.filter(t => {
const order = getOrder(t.id)
if (filter === 'free' && order) return false
if (filter === 'mine' && !(order && order.waiters?.some(w => w.waiter_id === user?.id))) return false
if (filter === 'mine' && !isMyOrder(order)) return false
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
return true
})
function handleLogout() {
logout()
navigate('/login')
}
const zoneActive = selectedZones.size > 0
function handleQuickAction(tableId, actionKey) {
// Navigate to table then trigger action via URL param so TableDetailPage can handle it
navigate(`/tables/${tableId}?action=${actionKey}`)
}
return (
<div className="page">
<header className="top-bar">
<span className="top-bar__title">Τραπέζια</span>
<span className="top-bar__user">{user?.username}</span>
<button className="icon-btn" onClick={handleLogout} title="Αποσύνδεση"></button>
<button
onClick={() => { setShowNotifs(true); fetchRecent?.() }}
style={{
position: 'relative', background: 'none', border: 'none',
color: 'var(--text)', fontSize: 22, cursor: 'pointer',
minWidth: 44, minHeight: 44, borderRadius: 8,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
</svg>
{(unreadCount || 0) > 0 && (
<span style={{
position: 'absolute', top: 6, right: 6,
background: '#ef4444', color: 'white',
fontSize: 10, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
<UserMenu />
</header>
{offline && <ConnectionBanner />}
@@ -95,7 +328,6 @@ export default function TableListPage() {
</button>
))}
{/* Zone filter */}
<div ref={zoneRef} style={{ position: 'relative' }}>
<button
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
@@ -106,16 +338,16 @@ export default function TableListPage() {
{zoneOpen && (
<div style={{
position: 'absolute', top: '110%', right: 0, zIndex: 100,
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', minWidth: 180, padding: 8,
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
}}>
<button
onClick={() => setSelectedZones(new Set())}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.size === 0 ? '#fff' : '#374151',
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -128,8 +360,8 @@ export default function TableListPage() {
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has(g.id) ? '#fff' : '#374151',
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -143,8 +375,8 @@ export default function TableListPage() {
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has('none') ? '#fff' : '#374151',
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -156,19 +388,52 @@ export default function TableListPage() {
</div>
</div>
<div className="table-grid">
{filtered.map(t => (
<TableCard
key={t.id}
table={t}
order={getOrder(t.id)}
currentUserId={user?.id}
onClick={() => navigate(`/tables/${t.id}`)}
/>
))}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
<div className="table-grid">
{filtered.map(t => {
const order = getOrder(t.id)
const tableFlags = tableFlagsMap[t.id] || []
const grp = groups.find(g => g.id === t.group_id)
// Free tables go straight to the item picker; occupied tables go to detail
const destination = order
? `/tables/${t.id}`
: `/tables/${t.id}/add?new=1`
return (
<TableCard
key={t.id}
table={t}
order={order}
isMine={isMyOrder(order)}
flags={tableFlags}
groupName={grp?.name || ''}
onClick={() => navigate(destination)}
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
/>
)
})}
</div>
<button className="fab" onClick={load} title="Ανανέωση"></button>
</div>
<button className="fab" onClick={load} title="Ανανέωση"></button>
{showNotifs && (
<NotificationDrawer
messages={recentMessages || []}
onClose={() => setShowNotifs(false)}
onAck={ackMessage}
/>
)}
{quickModal && (
<TableQuickModal
table={quickModal.table}
order={quickModal.order}
flags={quickModal.flags}
onClose={() => setQuickModal(null)}
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { create } from 'zustand'
const useShiftStore = create((set) => ({
shift: null,
businessDay: null,
selfStartAllowed: true,
selfEndAllowed: true,
gateStatus: 'loading', // 'loading' | 'closed' | 'needs_start' | 'waiting_manager' | 'ready'
setShift: (shift) => set({ shift }),
setBusinessDay: (day) => set({ businessDay: day }),
setSelfStartAllowed: (v) => set({ selfStartAllowed: v }),
setSelfEndAllowed: (v) => set({ selfEndAllowed: v }),
setGateStatus: (s) => set({ gateStatus: s }),
// Called when waiter ends their shift — sends them back to the start screen
clearShift: () => set({ shift: null, gateStatus: 'needs_start' }),
// Called on logout
clear: () => set({ shift: null, businessDay: null, gateStatus: 'loading' }),
}))
export default useShiftStore

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
export const DEFAULT_COLOURS = {
light: {
free: {
cardBg: '#dde5ef',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#3d5270',
badgeText: '#3d5270',
},
mine: {
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#81D264',
},
},
dark: {
free: {
cardBg: '#243044',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#94b8d4',
badgeText: '#94b8d4',
},
mine: {
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#81D264',
},
},
}
const useTableColourStore = create((set) => ({
colours: DEFAULT_COLOURS,
loadFromBackend: (raw) => {
try {
const parsed = JSON.parse(raw)
if (parsed?.light && parsed?.dark) {
// Deep-merge so any status keys added after the settings were saved
// (e.g. 'paid') still fall back to their defaults.
const merged = { light: {}, dark: {} }
for (const mode of ['light', 'dark']) {
for (const status of Object.keys(DEFAULT_COLOURS[mode])) {
merged[mode][status] = { ...DEFAULT_COLOURS[mode][status], ...(parsed[mode][status] || {}) }
}
}
set({ colours: merged })
}
} catch {}
},
}))
export default useTableColourStore

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useThemeStore = create(persist(
(set) => ({
dark: true,
toggle: () => set(s => ({ dark: !s.dark })),
}),
{ name: 'pos-theme' }
))
export default useThemeStore