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>
513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
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>
|
|
)
|
|
}
|