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:
512
manager_dashboard/src/pages/SetupWizard.jsx
Normal file
512
manager_dashboard/src/pages/SetupWizard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user