feat: initial commit — local services (backend + manager dashboard + waiter PWA)

Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View File

@@ -0,0 +1,512 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowRight, User, Mail, Lock, Building2, Coffee,
UtensilsCrossed, Beer, ShoppingBag, CheckCircle2, ChevronRight, Hash,
} from 'lucide-react'
import client from '../api/client'
import Button from '../ui/Button'
import { LabelledInput } from '../ui/Input'
// ─── Step indicator ───────────────────────────────────────────────────────────
function StepDots({ total, current }) {
return (
<div className="flex items-center gap-2">
{Array.from({ length: total }).map((_, i) => (
<div
key={i}
className={`rounded-full transition-all duration-300 ${
i < current
? 'w-2 h-2 bg-sky-400'
: i === current
? 'w-6 h-2 bg-sky-500'
: 'w-2 h-2 bg-slate-200'
}`}
/>
))}
</div>
)
}
// ─── Venue type selector (multi-select) ───────────────────────────────────────
const VENUE_TYPES = [
{ id: 'restaurant', label: 'Restaurant', Icon: UtensilsCrossed },
{ id: 'bar', label: 'Bar', Icon: Beer },
{ id: 'coffee_shop', label: 'Coffee Shop', Icon: Coffee },
{ id: 'fast_food', label: 'Fast Food', Icon: ShoppingBag },
]
function VenueTypeButton({ type, selected, onToggle }) {
const { label, Icon } = type
return (
<button
type="button"
onClick={() => onToggle(type.id)}
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-[13px] font-medium transition-all ${
selected
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
}`}
>
{selected && (
<div className="absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full bg-sky-500">
<CheckCircle2 className="h-3 w-3 text-white" />
</div>
)}
<Icon className={`h-6 w-6 ${selected ? 'text-sky-500' : 'text-slate-400'}`} />
{label}
</button>
)
}
// ─── PIN pad ──────────────────────────────────────────────────────────────────
const PIN_DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
function PinPad({ pin, onChange }) {
function press(d) {
if (d === '⌫') { onChange(p => p.slice(0, -1)); return }
if (d === '') return
if (pin.length >= 6) return
onChange(p => p + d)
}
return (
<div className="space-y-3">
<div className="flex justify-center gap-3 py-1">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
i < pin.length ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
}`}
/>
))}
</div>
<div className="grid grid-cols-3 gap-2">
{PIN_DIGITS.map((d, i) => (
<button
key={i}
type="button"
onClick={() => press(d)}
disabled={d === ''}
className={`h-12 rounded-xl text-lg font-semibold transition-colors ${
d === ''
? 'invisible'
: d === '⌫'
? 'bg-slate-100 hover:bg-slate-200 text-slate-600'
: 'bg-slate-100 hover:bg-sky-100 active:bg-sky-200 text-slate-800'
}`}
>
{d}
</button>
))}
</div>
</div>
)
}
// ─── Steps ───────────────────────────────────────────────────────────────────
function StepWelcome({ onNext }) {
return (
<div className="flex flex-col items-center text-center gap-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-sky-500 shadow-lg shadow-sky-200">
<UtensilsCrossed className="h-10 w-10 text-white" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-slate-900">Welcome to Xenia POS</h1>
<p className="text-slate-500 text-[14px] leading-relaxed max-w-xs">
The complete point-of-sale system for your venue. Let's get you set up
in just a few steps — it won't take long.
</p>
</div>
<div className="w-full rounded-xl border border-slate-100 bg-slate-50 p-4 text-left space-y-2">
{[
'Create your manager account',
'Tell us about your venue',
'You\'re ready to go',
].map((s, i) => (
<div key={i} className="flex items-center gap-3 text-[13px] text-slate-600">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-sky-600 text-[11px] font-semibold flex-shrink-0">
{i + 1}
</div>
{s}
</div>
))}
</div>
<Button variant="primary" className="w-full py-3 justify-center" onClick={onNext}>
Get Started <ArrowRight className="h-4 w-4" />
</Button>
<p className="text-[11px] text-slate-400">Thank you for choosing Xenia POS.</p>
</div>
)
}
function StepAccount({ data, onChange, onNext, onBack }) {
const [errors, setErrors] = useState({})
// Live password match: debounce 1s after confirm changes
const confirmTimer = useRef(null)
function handleConfirmChange(value) {
onChange('confirm', value)
clearTimeout(confirmTimer.current)
confirmTimer.current = setTimeout(() => {
if (value && data.password && value !== data.password) {
setErrors(e => ({ ...e, confirm: 'Passwords do not match' }))
} else {
setErrors(e => { const next = { ...e }; delete next.confirm; return next })
}
}, 1000)
}
// Also clear the error immediately if they now match
function handlePasswordChange(value) {
onChange('password', value)
if (data.confirm && value === data.confirm) {
setErrors(e => { const next = { ...e }; delete next.confirm; return next })
}
}
function validate() {
const e = {}
if (!data.username.trim()) e.username = 'Username is required'
if (!data.fullName.trim()) e.fullName = 'Full name is required'
if (!data.email.trim()) e.email = 'Email is required'
else if (!/\S+@\S+\.\S+/.test(data.email)) e.email = 'Enter a valid email'
if (data.password.length < 6) e.password = 'At least 6 characters'
if (data.password !== data.confirm) e.confirm = 'Passwords do not match'
setErrors(e)
return Object.keys(e).length === 0
}
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Create your manager account</h2>
<p className="text-[13px] text-slate-500">This will be the primary administrator account.</p>
</div>
<div className="space-y-3">
<div>
<LabelledInput
icon={User}
placeholder="Username"
value={data.username}
onChange={e => onChange('username', e.target.value)}
autoComplete="off"
autoFocus
/>
{errors.username && <p className="mt-1 text-[12px] text-rose-500">{errors.username}</p>}
</div>
<div>
<LabelledInput
icon={User}
placeholder="Full Name"
value={data.fullName}
onChange={e => onChange('fullName', e.target.value)}
autoComplete="off"
/>
{errors.fullName && <p className="mt-1 text-[12px] text-rose-500">{errors.fullName}</p>}
</div>
<div>
<LabelledInput
icon={Mail}
placeholder="Email address"
value={data.email}
onChange={e => onChange('email', e.target.value)}
type="email"
autoComplete="off"
/>
{errors.email && <p className="mt-1 text-[12px] text-rose-500">{errors.email}</p>}
</div>
<div>
<LabelledInput
icon={Lock}
placeholder="Password"
value={data.password}
onChange={e => handlePasswordChange(e.target.value)}
type="password"
autoComplete="new-password"
/>
{errors.password && <p className="mt-1 text-[12px] text-rose-500">{errors.password}</p>}
</div>
<div>
<LabelledInput
icon={Lock}
placeholder="Confirm Password"
value={data.confirm}
onChange={e => handleConfirmChange(e.target.value)}
type="password"
autoComplete="new-password"
/>
{errors.confirm && <p className="mt-1 text-[12px] text-rose-500">{errors.confirm}</p>}
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button variant="primary" className="flex-1 justify-center py-3" onClick={() => { if (validate()) onNext() }}>
Continue <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function StepVenue({ data, onChange, onNext, onBack }) {
const [errors, setErrors] = useState({})
function toggleType(id) {
const current = data.venueTypes
const next = current.includes(id)
? current.filter(v => v !== id)
: [...current, id]
onChange('venueTypes', next)
}
function validate() {
const e = {}
if (data.venueTypes.length === 0) e.venueType = 'Please select at least one type'
if (!data.venueName.trim()) e.venueName = 'Venue name is required'
setErrors(e)
return Object.keys(e).length === 0
}
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Tell us about your venue</h2>
<p className="text-[13px] text-slate-500">Select all that apply you can always change this later.</p>
</div>
<div className="space-y-4">
<div>
<p className="text-[12px] font-medium text-slate-500 uppercase tracking-wider mb-2">Venue type</p>
<div className="grid grid-cols-2 gap-2">
{VENUE_TYPES.map(type => (
<VenueTypeButton
key={type.id}
type={type}
selected={data.venueTypes.includes(type.id)}
onToggle={toggleType}
/>
))}
</div>
{errors.venueType && <p className="mt-1 text-[12px] text-rose-500">{errors.venueType}</p>}
</div>
<div>
<p className="text-[12px] font-medium text-slate-500 uppercase tracking-wider mb-2">Venue name</p>
<LabelledInput
icon={Building2}
placeholder="e.g. The Golden Fork"
value={data.venueName}
onChange={e => onChange('venueName', e.target.value)}
autoComplete="off"
/>
{errors.venueName && <p className="mt-1 text-[12px] text-rose-500">{errors.venueName}</p>}
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button variant="primary" className="flex-1 justify-center py-3" onClick={() => { if (validate()) onNext() }}>
Continue <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function StepPin({ pin, onChange, onNext, onBack }) {
const hasPin = pin.length >= 4
return (
<div className="flex flex-col gap-5">
<div className="space-y-1">
<h2 className="text-xl font-bold text-slate-900">Set a quick-access PIN</h2>
<p className="text-[13px] text-slate-500">
Optional. Used to unlock the dashboard quickly when it's locked,
without typing your full password.
</p>
</div>
<div className="rounded-xl border border-slate-100 bg-slate-50 p-5">
<PinPad pin={pin} onChange={onChange} />
</div>
{hasPin && (
<p className="text-center text-[12px] text-emerald-600 font-medium">
PIN set — {pin.length} digits
</p>
)}
<div className="flex gap-3">
<Button variant="ghost" className="flex-1 justify-center py-3" onClick={onBack}>
Back
</Button>
<Button
variant={hasPin ? 'primary' : 'secondary'}
className="flex-1 justify-center py-3"
onClick={onNext}
>
{hasPin ? <>Continue <ChevronRight className="h-4 w-4" /></> : 'Skip for now'}
</Button>
</div>
</div>
)
}
function StepDone({ onGoToLogin, loading, error }) {
return (
<div className="flex flex-col items-center text-center gap-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-200">
<CheckCircle2 className="h-10 w-10 text-white" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-slate-900">You're all set!</h2>
<p className="text-[14px] text-slate-500 leading-relaxed max-w-xs">
Your manager account is ready. Log in and start configuring your
products, tables, and staff.
</p>
</div>
<div className="w-full rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-left space-y-2">
{['Add your menu and products', 'Set up tables and zones', 'Add your waiters'].map((tip, i) => (
<div key={i} className="flex items-center gap-3 text-[13px] text-slate-600">
<ChevronRight className="h-4 w-4 text-emerald-500 flex-shrink-0" />
{tip}
</div>
))}
</div>
{error && <p className="text-[13px] text-rose-500">{error}</p>}
<Button
variant="primary"
className="w-full py-3 justify-center"
onClick={onGoToLogin}
disabled={loading}
>
{loading ? 'Setting up…' : 'Go to Login'}
{!loading && <ArrowRight className="h-4 w-4" />}
</Button>
</div>
)
}
// ─── Main wizard ──────────────────────────────────────────────────────────────
// Steps: 0=welcome, 1=account, 2=venue, 3=pin, 4=done
const INNER_STEPS = 3 // steps 1-3 show the dot indicator
export default function SetupWizard() {
const navigate = useNavigate()
const [step, setStep] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [account, setAccount] = useState({
username: '', fullName: '', email: '', password: '', confirm: '',
})
const [venue, setVenue] = useState({ venueTypes: [], venueName: '' })
const [pin, setPin] = useState('')
function updateAccount(key, value) {
setAccount(prev => ({ ...prev, [key]: value }))
}
function updateVenue(key, value) {
setVenue(prev => ({ ...prev, [key]: value }))
}
async function handleFinish() {
setLoading(true)
setError('')
try {
await client.post('/api/setup/init', {
username: account.username.trim(),
password: account.password,
email: account.email.trim(),
full_name: account.fullName.trim(),
venue_type: venue.venueTypes.join(','),
venue_name: venue.venueName.trim(),
pin: pin.length >= 4 ? pin : undefined,
})
navigate('/login', { replace: true })
} catch (err) {
setError(err.response?.data?.detail || 'Something went wrong. Please try again.')
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8">
{/* Dot indicator for inner steps only */}
{step >= 1 && step <= INNER_STEPS && (
<div className="flex justify-center mb-6">
<StepDots total={INNER_STEPS} current={step - 1} />
</div>
)}
{step === 0 && <StepWelcome onNext={() => setStep(1)} />}
{step === 1 && (
<StepAccount
data={account}
onChange={updateAccount}
onNext={() => setStep(2)}
onBack={() => setStep(0)}
/>
)}
{step === 2 && (
<StepVenue
data={venue}
onChange={updateVenue}
onNext={() => setStep(3)}
onBack={() => setStep(1)}
/>
)}
{step === 3 && (
<StepPin
pin={pin}
onChange={setPin}
onNext={() => setStep(4)}
onBack={() => setStep(2)}
/>
)}
{step === 4 && (
<StepDone
onGoToLogin={handleFinish}
loading={loading}
error={error}
/>
)}
</div>
<p className="text-center text-[11px] text-slate-400 mt-4">
Xenia POS · First-time Setup
</p>
</div>
</div>
)
}