Phase 2: scaffold Waiter PWA — React+Vite, PWA manifest, all pages and components
This commit is contained in:
14
waiter_pwa/src/components/ConnectionBanner.jsx
Normal file
14
waiter_pwa/src/components/ConnectionBanner.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ConnectionBanner() {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textAlign: 'center',
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠ Cannot reach the system — check your WiFi
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
waiter_pwa/src/components/ItemOptionsModal.jsx
Normal file
97
waiter_pwa/src/components/ItemOptionsModal.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const [selectedOptions, setSelectedOptions] = useState([])
|
||||
const [removedIngredients, setRemovedIngredients] = useState([])
|
||||
const [notes, setNotes] = useState('')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const options = product.options || []
|
||||
const ingredients = product.ingredients || []
|
||||
|
||||
function toggleOption(opt) {
|
||||
setSelectedOptions(prev => {
|
||||
const exists = prev.find(o => o.id === opt.id)
|
||||
if (exists) return prev.filter(o => o.id !== opt.id)
|
||||
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.price_delta }]
|
||||
})
|
||||
}
|
||||
|
||||
function toggleIngredient(ing) {
|
||||
setRemovedIngredients(prev =>
|
||||
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
|
||||
)
|
||||
}
|
||||
|
||||
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta || 0), 0)
|
||||
const totalPrice = (product.base_price + extraPrice) * quantity
|
||||
|
||||
function handleAdd() {
|
||||
onAdd({ product_id: product.id, quantity, selected_options: selectedOptions, removed_ingredients: removedIngredients, notes })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title">{product.name}</h2>
|
||||
<p className="modal-price">{Number(totalPrice).toFixed(2)} €</p>
|
||||
|
||||
{options.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Επιλογές</h3>
|
||||
{options.map(opt => (
|
||||
<label key={opt.id} className="modal-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedOptions.find(o => o.id === opt.id)}
|
||||
onChange={() => toggleOption(opt)}
|
||||
/>
|
||||
<span>{opt.name}</span>
|
||||
{opt.price_delta > 0 && <span className="option-price">+{Number(opt.price_delta).toFixed(2)} €</span>}
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ingredients.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Αφαίρεση υλικών</h3>
|
||||
{ingredients.map(ing => (
|
||||
<label key={ing.id} className="modal-option modal-option--remove">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removedIngredients.includes(ing.name)}
|
||||
onChange={() => toggleIngredient(ing)}
|
||||
/>
|
||||
<span>χωρίς {ing.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="modal-section">
|
||||
<h3>Σημείωση</h3>
|
||||
<textarea
|
||||
className="modal-notes"
|
||||
placeholder="π.χ. χωρίς αλάτι..."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="modal-qty">
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.max(1, q - 1))}>−</button>
|
||||
<span className="qty-value">{quantity}</span>
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary btn--lg" onClick={handleAdd} style={{ width: '100%', marginTop: 16 }}>
|
||||
Προσθήκη στην παραγγελία
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
waiter_pwa/src/components/OrderSummary.jsx
Normal file
61
waiter_pwa/src/components/OrderSummary.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
function fmtPrice(v) {
|
||||
return Number(v).toFixed(2) + ' €'
|
||||
}
|
||||
|
||||
function ItemRow({ item, selectable, selected, onToggle }) {
|
||||
const isPaid = item.status === 'paid'
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
|
||||
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 {}
|
||||
|
||||
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' }}
|
||||
>
|
||||
<div className="order-item__row">
|
||||
{selectable && !isPaid && !isCancelled && (
|
||||
<span style={{ marginRight: 8, color: selected ? '#f59e0b' : '#475569' }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
<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>}
|
||||
</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>)}
|
||||
{item.notes && <div className="order-item__modifier">📝 {item.notes}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle }) {
|
||||
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
|
||||
const total = activeItems.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 => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectable={selectable}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
<div className="order-summary__total">
|
||||
<span>Σύνολο</span>
|
||||
<span>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
waiter_pwa/src/components/PinPad.jsx
Normal file
37
waiter_pwa/src/components/PinPad.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function PinPad({ onSubmit, loading }) {
|
||||
const [pin, setPin] = useState('')
|
||||
|
||||
function press(digit) {
|
||||
if (pin.length < 8) setPin(p => p + digit)
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
setPin(p => p.slice(0, -1))
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (pin.length > 0 && !loading) onSubmit(pin)
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: 8 }, (_, i) => (
|
||||
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}>●</span>
|
||||
))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>{dots}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
|
||||
{[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={() => press('0')} className="pin-btn">0</button>
|
||||
<button onClick={submit} className="pin-btn pin-btn--confirm" disabled={loading || pin.length === 0}>
|
||||
{loading ? '…' : '✓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
waiter_pwa/src/components/ProductPicker.jsx
Normal file
47
waiter_pwa/src/components/ProductPicker.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import ItemOptionsModal from './ItemOptionsModal'
|
||||
|
||||
export default function ProductPicker({ categories, products, onAdd }) {
|
||||
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
|
||||
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||
|
||||
const filtered = products.filter(p => p.category_id === activeCat)
|
||||
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-tab ${activeCat === cat.id ? 'cat-tab--active' : ''}`}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProduct && (
|
||||
<ItemOptionsModal
|
||||
product={selectedProduct}
|
||||
onAdd={onAdd}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
waiter_pwa/src/components/TableCard.jsx
Normal file
23
waiter_pwa/src/components/TableCard.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default function TableCard({ table, order, currentUserId, onClick }) {
|
||||
const hasOrder = !!order
|
||||
const isMyTable = hasOrder && order.waiters?.some(w => w.waiter_id === currentUserId)
|
||||
|
||||
let statusLabel = 'Ελεύθερο'
|
||||
let cardClass = 'table-card table-card--free'
|
||||
|
||||
if (hasOrder && isMyTable) {
|
||||
statusLabel = 'Δικό μου'
|
||||
cardClass = 'table-card table-card--mine'
|
||||
} else if (hasOrder) {
|
||||
statusLabel = 'Ενεργό'
|
||||
cardClass = 'table-card table-card--active'
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cardClass} onClick={onClick}>
|
||||
<span className="table-card__number">{table.number}</span>
|
||||
{table.name && <span className="table-card__name">{table.name}</span>}
|
||||
<span className="table-card__status">{statusLabel}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user