Compare commits

...

9 Commits

Author SHA1 Message Date
b45a93a559 feat(connect): Phase 7 — Docker & nginx for connect_frontend
connect_frontend/Dockerfile
  Multi-stage build: node:20-alpine builds both menu-app and
  manager-app in parallel stages, then nginx:alpine serves both
  dist folders under /menu and /manage respectively.
  VITE_CLOUD_URL passed as build arg so the API URL is baked
  in at build time (standard Vite pattern).

connect_frontend/nginx-connect.conf
  Single nginx server block:
    /menu/   → /usr/share/nginx/html/menu  (SPA fallback)
    /manage/ → /usr/share/nginx/html/manage (SPA fallback)
    /health  → 200 ok

nginx-connect.conf
  Kept at cloud-service root for reference / standalone use.

docker-compose.yml
  connect_frontend service added:
    - build context: ./connect_frontend
    - VITE_CLOUD_URL passed from root .env
    - port 3100:80
    - depends_on cloud_backend

Individual Dockerfiles also added to menu-app/ and manager-app/
for standalone builds if needed.

To deploy:
  docker compose build connect_frontend
  docker compose up -d connect_frontend

  Public menu:    http://<host>:3100/menu/<site_slug>
  Manager app:    http://<host>:3100/manage/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 01:00:56 +03:00
7d75ef44f7 fix: explicitly import Site in manager_account to resolve relationship
SQLAlchemy couldn't resolve the string 'Site' in ManagerAccount's
relationship() at query time because Site wasn't guaranteed to be
imported first. Adding the explicit import ensures the mapper is
configured correctly regardless of import order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:57:02 +03:00
3598a929e0 fix: switch admin auth to HTTPBearer for consistent Swagger UI
Same fix as manager_auth — OAuth2PasswordBearer was showing an OAuth2
form in Swagger instead of a plain Bearer token input, causing
'Not authenticated' when testing via the docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:54:30 +03:00
f291234c14 fix: switch manager auth from OAuth2PasswordBearer to HTTPBearer
OAuth2PasswordBearer caused Swagger UI to show a username/password/
client_id form instead of a simple token input, making the docs
unusable for testing. HTTPBearer shows a plain 'Bearer token' field,
consistent with how the local_backend handles auth.

No runtime behaviour change — token extraction is identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:49:09 +03:00
6c5e35d537 fix: use AppData path for SQLite DB on Windows, absolute path on Linux
The default sqlite:////app/data/cloud.db path only works inside Docker.
On Windows, Controlled Folder Access blocks writes to arbitrary paths.

Now mirrors the same pattern as local_backend/config.py:
  - Windows: %LOCALAPPDATA%/xenia_cloud/cloud.db (always writable)
  - Linux/Docker: next to main.py (overridden by DATABASE_URL in .env)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:38:50 +03:00
12bd589bb7 feat(connect): Phase 6 — menu-app and manager-app frontend SPAs
Two Vite + React 18 + Tailwind apps under connect_frontend/.
Both use React Router v6, Axios, react-hot-toast, and Lucide icons.
Neither uses localStorage — tokens live in sessionStorage (manager-app)
or component state (menu-app cart).

── menu-app (public menu + ordering) ──────────────────────────────

Routing (basename /menu):
  /:siteSlug              → MenuPage
  /:siteSlug/order        → CartPage
  /:siteSlug/confirm/:ref → OrderConfirm

Pages:
  MenuPage      — fetches menu snapshot, category nav tabs, product
                  cards; floating CartButton; opens ProductModal
  ProductModal  — quantity picker, quick-option toggles, price/discount
                  display, "Add to order" with line total
  CartPage      — order type selector (dine_in/delivery), customer
                  details form, cart summary, submits to cloud API
  OrderConfirm  — polls GET /api/orders/status/:ref every 10s;
                  renders status icon + label; stops polling on
                  terminal states (delivered/rejected)

Components:
  CategoryNav   — horizontal scrollable pill tabs
  ProductCard   — image, name, description, price with discount badge;
                  greyed + "Out of stock" badge when unavailable
  CartButton    — fixed bottom bar with item count + total

── manager-app (remote dashboard) ─────────────────────────────────

Routing (basename /manage):
  /login              → LoginPage
  /                   → SiteSelectorPage (auto-navigates if one site)
  /:siteId            → DashboardPage
  /:siteId/orders     → IncomingOrdersPage
  /:siteId/orders/history → OrderHistoryPage
  /:siteId/orders/:id → OrderDetailPage
  RequireAuth wrapper redirects to /login if no sessionStorage token

Pages:
  LoginPage          — email/password → POST /api/manager/login;
                       stores JWT in sessionStorage
  SiteSelectorPage   — lists accessible venues; auto-selects if one
  DashboardPage      — polls snapshot + pending orders every 15s;
                       2×2 stat grid + pending order list
  IncomingOrdersPage — polls pending orders every 15s; order cards
  OrderDetailPage    — full order detail; Accept/Reject with optional
                       rejection reason; lifecycle progression buttons
                       (Preparing → Ready → Delivered etc.)
  OrderHistoryPage   — all orders with status filter tabs

Components:
  RequireAuth   — route guard using sessionStorage token
  StatCard      — labelled metric tile
  StatusBadge   — colour-coded status pill
  OrderCard     — summary card linking to OrderDetailPage
  (no OrderCard uses useParams to get siteId for nav — wired correctly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:25:28 +03:00
abe7f4ff0d feat(connect): return site_numeric_id in heartbeat response
Local backend needs the cloud DB integer PK to call Connect API
endpoints (menu sync, order pending poll). Heartbeat is the only
authenticated channel available at startup, so we piggyback the id
there rather than adding a new endpoint.

Changes:
  - schemas/site.py: site_numeric_id: int | None added to HeartbeatResponse
  - routers/heartbeat.py: site_numeric_id=site.id included in response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:11:48 +03:00
cd8a6c53cf feat(connect): Phase 3 — cloud API routes for Xenia Connect
Adds four new routers, three schema files, and one new model.
All new endpoints use new URL prefixes — no existing routes touched.

routers/menu.py
  GET  /api/menu/{site_slug}     — public menu snapshot (no auth)
  POST /api/menu/sync            — upsert snapshot (site API key)

routers/orders.py
  POST /api/orders/{site_slug}         — customer places order (public)
  GET  /api/orders/status/{public_ref} — customer tracks order (public)
  GET  /api/orders/pending/{site_id}   — local backend polls (site key)
  POST /api/orders/{id}/synced         — mark synced (site key)
  PATCH /api/orders/{id}/status        — update status with transition
                                         validation (site key)

routers/manager_auth.py
  POST /api/manager/register  — create manager account (admin JWT)
  POST /api/manager/login     — returns manager JWT
  POST /api/manager/refresh   — refreshes manager JWT
  Shared _get_current_manager dependency used by remote_dashboard

routers/remote_dashboard.py
  GET  /api/remote/sites                           — manager JWT
  GET  /api/remote/sites/{id}/snapshot             — manager JWT
  GET  /api/remote/sites/{id}/orders               — manager JWT
  GET  /api/remote/sites/{id}/orders/pending       — manager JWT
  PATCH /api/remote/sites/{id}/orders/{oid}/status — manager JWT
  POST /api/remote/snapshot                        — site API key
                                                     (stats push)

models/stats_snapshot.py (NEW)
  stats_snapshots table — one row per site, holds serialized
  operational stats; created by create_all on startup

config.py / .env.example
  MANAGER_JWT_SECRET and MANAGER_JWT_EXPIRE_HOURS (default 72h)
  added as separate settings from the admin SECRET_KEY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:05:48 +03:00
835806f92d feat(connect): Phase 2 — cloud DB models for Xenia Connect
Adds three new tables created automatically by create_all on next
startup, plus an additive migration for an existing table:

models/menu_snapshot.py (NEW)
  - menu_snapshots: one row per site, holds the full serialized
    category+product JSON; upserted on each sync from local_backend

models/online_order.py (NEW)
  - online_orders: full order lifecycle from customer submission
    through delivery; tracks sync state so local_backend can poll
    for unprocessed orders (synced_to_local=0)

models/manager_account.py (NEW)
  - manager_accounts + manager_site_access (M2M join table): remote
    manager login accounts with per-site access control

models/site.py (MODIFIED)
  - order_counter column added; incremented atomically when each
    online order is created to generate "ORD-XXXX" public_ref values

main.py (MODIFIED)
  - imports all three new models so create_all registers their tables
  - _run_migrations() added (same pattern as local_backend) for the
    additive order_counter column on the existing sites table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:01:02 +03:00
59 changed files with 2378 additions and 10 deletions

View File

@@ -15,3 +15,8 @@ ADMIN_PASSWORD=changeme
# The current latest release version — clients compare against this to detect updates
LATEST_VERSION=0.0.0
# Xenia Connect — manager JWT (use a different secret from SECRET_KEY)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
MANAGER_JWT_SECRET=change-me-manager-secret
MANAGER_JWT_EXPIRE_HOURS=72

View File

@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from config import settings
@@ -10,7 +10,7 @@ from database import get_db
from models.admin import Admin
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
_bearer = HTTPBearer()
ALGORITHM = "HS256"
@@ -29,9 +29,9 @@ def create_access_token(data: dict) -> str:
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
def get_current_admin(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> Admin:
def get_current_admin(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> Admin:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")

View File

@@ -1,16 +1,27 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings
_HERE = Path(__file__).parent
if os.name == "nt":
_DB_DIR = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "xenia_cloud"
_DB_DIR.mkdir(parents=True, exist_ok=True)
_DEFAULT_DB = _DB_DIR / "cloud.db"
else:
_DEFAULT_DB = _HERE / "cloud.db"
class Settings(BaseSettings):
SECRET_KEY: str = "change-me-generate-a-long-random-string"
DATABASE_URL: str = "sqlite:////app/data/cloud.db"
DATABASE_URL: str = f"sqlite:///{_DEFAULT_DB.as_posix()}"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ADMIN_USERNAME: str = "sysadmin"
ADMIN_PASSWORD: str = "changeme"
LATEST_VERSION: str = "0.0.0"
# Manager JWT (separate secret from admin JWT)
MANAGER_JWT_SECRET: str = "change-me-manager-secret"
MANAGER_JWT_EXPIRE_HOURS: int = 72
model_config = {"env_file": str(_HERE / ".env")}

View File

@@ -5,10 +5,18 @@ from fastapi.middleware.cors import CORSMiddleware
from config import settings
from database import engine, Base
from auth_utils import hash_password
import models.admin # noqa: F401
import models.site # noqa: F401
import models.admin # noqa: F401
import models.site # noqa: F401
import models.menu_snapshot # noqa: F401
import models.online_order # noqa: F401
import models.manager_account # noqa: F401
import models.stats_snapshot # noqa: F401
from routers import auth, sites, heartbeat
from routers import menu as menu_router
from routers import orders as orders_router
from routers import manager_auth as manager_auth_router
from routers import remote_dashboard as remote_dashboard_router
def _seed_default_admin():
@@ -25,9 +33,26 @@ def _seed_default_admin():
db.commit()
def _run_migrations():
"""Apply additive schema changes that create_all won't handle."""
from sqlalchemy import text
migrations = [
# Per-site order counter for public_ref generation (e.g. "ORD-0042")
"ALTER TABLE sites ADD COLUMN order_counter INTEGER NOT NULL DEFAULT 0",
]
for sql in migrations:
try:
with engine.connect() as conn:
conn.execute(text(sql))
conn.commit()
except Exception:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_run_migrations()
_seed_default_admin()
yield
@@ -41,9 +66,13 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
app.include_router(menu_router.router, prefix="/api/menu", tags=["menu"])
app.include_router(orders_router.router, prefix="/api/orders", tags=["orders"])
app.include_router(manager_auth_router.router, prefix="/api/manager", tags=["manager"])
app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags=["remote"])
@app.get("/health")

View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, DateTime, Table, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
from models.site import Site # noqa: F401 — needed so SQLAlchemy resolves "Site" in relationship
# Many-to-many: one manager can access multiple sites, one site can have multiple managers
manager_site_access = Table(
"manager_site_access",
Base.metadata,
Column("manager_id", Integer, ForeignKey("manager_accounts.id"), primary_key=True),
Column("site_id", Integer, ForeignKey("sites.id"), primary_key=True),
)
class ManagerAccount(Base):
__tablename__ = "manager_accounts"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
full_name = Column(String, nullable=True)
is_active = Column(Integer, default=1, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
sites = relationship("Site", secondary=manager_site_access, backref="manager_accounts")

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from database import Base
class MenuSnapshot(Base):
__tablename__ = "menu_snapshots"
id = Column(Integer, primary_key=True, index=True)
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True, unique=True)
snapshot_json = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey
from sqlalchemy.sql import func
from database import Base
class OnlineOrder(Base):
__tablename__ = "online_orders"
id = Column(Integer, primary_key=True, index=True)
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True)
# Human-readable reference, unique per site (e.g. "ORD-0042")
public_ref = Column(String, unique=True, nullable=False)
order_type = Column(String, nullable=False) # "delivery" | "dine_in"
# Customer info
customer_name = Column(String, nullable=False)
customer_phone = Column(String, nullable=True)
customer_address = Column(Text, nullable=True) # required for delivery
customer_notes = Column(Text, nullable=True)
# Order content — JSON array of ordered items
items_json = Column(Text, nullable=False)
# Financials
subtotal = Column(Float, nullable=False)
delivery_fee = Column(Float, default=0.0, nullable=False)
total = Column(Float, nullable=False)
# Status lifecycle:
# pending_acceptance → accepted | rejected
# accepted → preparing → ready | out_for_delivery → delivered
status = Column(String, default="pending_acceptance", nullable=False, index=True)
rejection_reason = Column(String, nullable=True)
# Sync state — local backend polls for these
synced_to_local = Column(Integer, default=0, nullable=False) # 0=no, 1=yes
local_order_id = Column(Integer, nullable=True) # set after local creates it
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -21,3 +21,5 @@ class Site(Base):
last_seen_ip = Column(String, nullable=True)
last_seen_local_ip = Column(String, nullable=True)
waiter_domain = Column(String, nullable=True)
# Monotonically incrementing counter used to generate public_ref for online orders
order_counter = Column(Integer, default=0, nullable=False)

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from database import Base
class StatsSnapshot(Base):
__tablename__ = "stats_snapshots"
id = Column(Integer, primary_key=True)
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, unique=True)
snapshot_json = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -39,4 +39,5 @@ def heartbeat(
expires_at=site.license_expires_at,
latest_version=settings.LATEST_VERSION,
waiter_domain=site.waiter_domain,
site_numeric_id=site.id,
)

View File

@@ -0,0 +1,91 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount
from models.site import Site
from auth_utils import get_current_admin
from schemas.manager import (
ManagerRegisterRequest, ManagerLoginRequest,
ManagerTokenOut, ManagerOut,
)
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
_bearer = HTTPBearer()
ALGORITHM = "HS256"
# ── Shared dependency: decode manager JWT ─────────────────────────────────────
def _get_current_manager(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> ManagerAccount:
try:
payload = jwt.decode(credentials.credentials, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM])
manager_id = int(payload.get("sub", 0))
except (JWTError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first()
if not manager or not manager.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive")
return manager
def _create_manager_token(manager: ManagerAccount) -> str:
site_ids = [s.id for s in manager.sites]
payload = {
"sub": str(manager.id),
"email": manager.email,
"site_ids": site_ids,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM)
# ── Admin-only: register a manager ───────────────────────────────────────────
@router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED)
def register_manager(
body: ManagerRegisterRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
manager = ManagerAccount(
email=body.email,
password_hash=_pwd.hash(body.password),
full_name=body.full_name,
sites=sites,
)
db.add(manager)
db.commit()
db.refresh(manager)
return manager
# ── Public: manager login ─────────────────────────────────────────────────────
@router.post("/login", response_model=ManagerTokenOut)
def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
if not manager or not _pwd.verify(body.password, manager.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not manager.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Public: refresh ───────────────────────────────────────────────────────────
@router.post("/refresh", response_model=ManagerTokenOut)
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
return ManagerTokenOut(access_token=_create_manager_token(manager))

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, Header, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models.site import Site
from models.menu_snapshot import MenuSnapshot
from schemas.menu import MenuSyncRequest, MenuSyncResponse
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _require_site(
x_site_id: str = Header(..., alias="X-Site-ID"),
x_site_key: str = Header(..., alias="X-Site-Key"),
db: Session = Depends(get_db),
) -> Site:
site = db.query(Site).filter(Site.site_id == x_site_id).first()
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
return site
# ── Public ────────────────────────────────────────────────────────────────────
@router.get("/{site_slug}")
def get_menu(site_slug: str, db: Session = Depends(get_db)):
"""Return the latest menu snapshot for a site. Used by the public menu SPA."""
site = db.query(Site).filter(Site.site_id == site_slug).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == site.id).first()
if not snapshot:
raise HTTPException(status_code=404, detail="No menu published yet")
import json
return json.loads(snapshot.snapshot_json)
# ── Internal (site API key) ───────────────────────────────────────────────────
@router.post("/sync", response_model=MenuSyncResponse)
def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Session = Depends(get_db)):
"""Upsert the menu snapshot for this site. Called by local_backend on each sync."""
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == body.site_id).first()
if snapshot:
snapshot.snapshot_json = body.snapshot_json
else:
snapshot = MenuSnapshot(site_id=body.site_id, snapshot_json=body.snapshot_json)
db.add(snapshot)
db.commit()
return MenuSyncResponse(ok=True)

View File

@@ -0,0 +1,141 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Header, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models.site import Site
from models.online_order import OnlineOrder
from schemas.online_order import (
OnlineOrderCreate, OnlineOrderCreated, OrderStatusOut,
PendingOrderOut, OrderSyncedRequest, OrderStatusUpdateRequest,
)
from auth_utils import get_current_admin # manager JWT checked separately in manager_auth
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Valid status transitions
_TRANSITIONS: dict[str, set[str]] = {
"pending_acceptance": {"accepted", "rejected"},
"accepted": {"preparing"},
"preparing": {"ready", "out_for_delivery"},
"ready": {"delivered"},
"out_for_delivery": {"delivered"},
}
def _require_site(
x_site_id: str = Header(..., alias="X-Site-ID"),
x_site_key: str = Header(..., alias="X-Site-Key"),
db: Session = Depends(get_db),
) -> Site:
site = db.query(Site).filter(Site.site_id == x_site_id).first()
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
return site
# ── Public endpoints ──────────────────────────────────────────────────────────
@router.post("/{site_slug}", response_model=OnlineOrderCreated, status_code=status.HTTP_201_CREATED)
def create_order(site_slug: str, body: OnlineOrderCreate, db: Session = Depends(get_db)):
"""Customer submits an order from the public menu SPA."""
site = db.query(Site).filter(Site.site_id == site_slug).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
# Atomically increment counter and generate public_ref
site.order_counter = (site.order_counter or 0) + 1
public_ref = f"ORD-{site.order_counter:04d}"
order = OnlineOrder(
site_id=site.id,
public_ref=public_ref,
order_type=body.order_type,
customer_name=body.customer_name,
customer_phone=body.customer_phone,
customer_address=body.customer_address,
customer_notes=body.customer_notes,
items_json=json.dumps([item.model_dump() for item in body.items]),
subtotal=body.subtotal,
delivery_fee=body.delivery_fee,
total=body.total,
status="pending_acceptance",
)
db.add(order)
db.commit()
db.refresh(order)
return OnlineOrderCreated(order_id=order.id, public_ref=order.public_ref, status=order.status)
@router.get("/status/{public_ref}", response_model=OrderStatusOut)
def get_order_status(public_ref: str, db: Session = Depends(get_db)):
"""Customer polls this to track their order."""
order = db.query(OnlineOrder).filter(OnlineOrder.public_ref == public_ref).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return OrderStatusOut(
public_ref=order.public_ref,
status=order.status,
rejection_reason=order.rejection_reason,
)
# ── Internal endpoints (site API key) ─────────────────────────────────────────
@router.get("/pending/{site_id}", response_model=list[PendingOrderOut])
def get_pending_orders(site_id: int, site: Site = Depends(_require_site), db: Session = Depends(get_db)):
"""Return all orders not yet synced to the local backend."""
orders = (
db.query(OnlineOrder)
.filter(OnlineOrder.site_id == site_id, OnlineOrder.synced_to_local == 0)
.order_by(OnlineOrder.created_at)
.all()
)
return orders
@router.post("/{order_id}/synced", status_code=status.HTTP_204_NO_CONTENT)
def mark_order_synced(
order_id: int,
body: OrderSyncedRequest,
site: Site = Depends(_require_site),
db: Session = Depends(get_db),
):
"""Local backend calls this after it has created the order locally."""
order = db.query(OnlineOrder).filter(
OnlineOrder.id == order_id, OnlineOrder.site_id == site.id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.synced_to_local = 1
order.local_order_id = body.local_order_id
db.commit()
@router.patch("/{order_id}/status", status_code=status.HTTP_204_NO_CONTENT)
def update_order_status(
order_id: int,
body: OrderStatusUpdateRequest,
site: Site = Depends(_require_site),
db: Session = Depends(get_db),
):
"""Update order status. Called by local_backend after staff accept/reject/progress."""
order = db.query(OnlineOrder).filter(
OnlineOrder.id == order_id, OnlineOrder.site_id == site.id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
allowed = _TRANSITIONS.get(order.status, set())
if body.status not in allowed:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Cannot transition from '{order.status}' to '{body.status}'",
)
order.status = body.status
if body.rejection_reason:
order.rejection_reason = body.rejection_reason
db.commit()

View File

@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models.manager_account import ManagerAccount
from models.online_order import OnlineOrder
from models.stats_snapshot import StatsSnapshot
from schemas.manager import StatsSnapshotOut, StatsSnapshotRequest
from routers.manager_auth import _get_current_manager
router = APIRouter()
# ── Sites the manager can access ──────────────────────────────────────────────
@router.get("/sites")
def list_sites(manager: ManagerAccount = Depends(_get_current_manager)):
return [{"id": s.id, "site_id": s.site_id, "name": s.name} for s in manager.sites]
# ── Stats snapshot ────────────────────────────────────────────────────────────
@router.get("/sites/{site_id}/snapshot", response_model=StatsSnapshotOut)
def get_stats_snapshot(
site_id: int,
manager: ManagerAccount = Depends(_get_current_manager),
db: Session = Depends(get_db),
):
_check_site_access(manager, site_id)
snap = db.query(StatsSnapshot).filter(StatsSnapshot.site_id == site_id).first()
if not snap:
raise HTTPException(status_code=404, detail="No snapshot available yet")
return snap
# ── Orders ────────────────────────────────────────────────────────────────────
@router.get("/sites/{site_id}/orders")
def list_orders(
site_id: int,
order_status: str | None = None,
limit: int = 50,
offset: int = 0,
manager: ManagerAccount = Depends(_get_current_manager),
db: Session = Depends(get_db),
):
_check_site_access(manager, site_id)
q = db.query(OnlineOrder).filter(OnlineOrder.site_id == site_id)
if order_status:
q = q.filter(OnlineOrder.status == order_status)
orders = q.order_by(OnlineOrder.created_at.desc()).offset(offset).limit(limit).all()
return _serialize_orders(orders)
@router.get("/sites/{site_id}/orders/pending")
def list_pending_orders(
site_id: int,
manager: ManagerAccount = Depends(_get_current_manager),
db: Session = Depends(get_db),
):
_check_site_access(manager, site_id)
orders = (
db.query(OnlineOrder)
.filter(OnlineOrder.site_id == site_id, OnlineOrder.status == "pending_acceptance")
.order_by(OnlineOrder.created_at)
.all()
)
return _serialize_orders(orders)
# ── Stats snapshot push (site API key — called by local_backend) ──────────────
# Imported and registered from main.py under a site-key-protected sub-path.
# Defined here as a plain function so remote_dashboard router stays manager-JWT only.
from fastapi import Header
from passlib.context import CryptContext
from models.site import Site
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
@router.post("/snapshot", status_code=status.HTTP_204_NO_CONTENT)
def push_stats_snapshot(
body: StatsSnapshotRequest,
x_site_id: str = Header(..., alias="X-Site-ID"),
x_site_key: str = Header(..., alias="X-Site-Key"),
db: Session = Depends(get_db),
):
"""Local backend pushes stats every 5 minutes. Auth: site API key."""
site = db.query(Site).filter(Site.site_id == x_site_id).first()
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
snap = db.query(StatsSnapshot).filter(StatsSnapshot.site_id == body.site_id).first()
if snap:
snap.snapshot_json = body.snapshot_json
else:
snap = StatsSnapshot(site_id=body.site_id, snapshot_json=body.snapshot_json)
db.add(snap)
db.commit()
# ── Manager can also update order status (goes via cloud, local polls) ─────────
from schemas.online_order import OrderStatusUpdateRequest
_TRANSITIONS: dict[str, set[str]] = {
"pending_acceptance": {"accepted", "rejected"},
"accepted": {"preparing"},
"preparing": {"ready", "out_for_delivery"},
"ready": {"delivered"},
"out_for_delivery": {"delivered"},
}
@router.patch("/sites/{site_id}/orders/{order_id}/status", status_code=status.HTTP_204_NO_CONTENT)
def manager_update_order_status(
site_id: int,
order_id: int,
body: OrderStatusUpdateRequest,
manager: ManagerAccount = Depends(_get_current_manager),
db: Session = Depends(get_db),
):
_check_site_access(manager, site_id)
order = db.query(OnlineOrder).filter(
OnlineOrder.id == order_id, OnlineOrder.site_id == site_id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
allowed = _TRANSITIONS.get(order.status, set())
if body.status not in allowed:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Cannot transition from '{order.status}' to '{body.status}'",
)
order.status = body.status
if body.rejection_reason:
order.rejection_reason = body.rejection_reason
db.commit()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_site_access(manager: ManagerAccount, site_id: int):
if not any(s.id == site_id for s in manager.sites):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this site")
def _serialize_orders(orders):
return [
{
"id": o.id,
"public_ref": o.public_ref,
"order_type": o.order_type,
"customer_name": o.customer_name,
"customer_phone": o.customer_phone,
"customer_address": o.customer_address,
"customer_notes": o.customer_notes,
"subtotal": o.subtotal,
"delivery_fee": o.delivery_fee,
"total": o.total,
"status": o.status,
"rejection_reason": o.rejection_reason,
"synced_to_local": o.synced_to_local,
"local_order_id": o.local_order_id,
"created_at": o.created_at.isoformat() if o.created_at else None,
"updated_at": o.updated_at.isoformat() if o.updated_at else None,
}
for o in orders
]

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ManagerRegisterRequest(BaseModel):
email: str
password: str
full_name: Optional[str] = None
site_ids: list[int] = []
class ManagerLoginRequest(BaseModel):
email: str
password: str
class ManagerTokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
class ManagerSiteOut(BaseModel):
id: int
site_id: str
name: str
model_config = {"from_attributes": True}
class ManagerOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
created_at: datetime
sites: list[ManagerSiteOut] = []
model_config = {"from_attributes": True}
class StatsSnapshotOut(BaseModel):
site_id: int
snapshot_json: str
updated_at: datetime
model_config = {"from_attributes": True}
class StatsSnapshotRequest(BaseModel):
site_id: int
snapshot_json: str

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class MenuSyncRequest(BaseModel):
site_id: int
snapshot_json: str
class MenuSyncResponse(BaseModel):
ok: bool

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class OrderItemIn(BaseModel):
product_id: int
name: str
quantity: int
unit_price: float
options: list = []
class OnlineOrderCreate(BaseModel):
order_type: str # "delivery" | "dine_in"
customer_name: str
customer_phone: Optional[str] = None
customer_address: Optional[str] = None
customer_notes: Optional[str] = None
items: list[OrderItemIn]
subtotal: float
delivery_fee: float = 0.0
total: float
class OnlineOrderCreated(BaseModel):
order_id: int
public_ref: str
status: str
class OrderStatusOut(BaseModel):
public_ref: str
status: str
rejection_reason: Optional[str] = None
class PendingOrderOut(BaseModel):
id: int
public_ref: str
order_type: str
customer_name: str
customer_phone: Optional[str] = None
customer_address: Optional[str] = None
customer_notes: Optional[str] = None
items_json: str
subtotal: float
delivery_fee: float
total: float
status: str
created_at: datetime
model_config = {"from_attributes": True}
class OrderSyncedRequest(BaseModel):
local_order_id: int
class OrderStatusUpdateRequest(BaseModel):
status: str
rejection_reason: Optional[str] = None

View File

@@ -57,3 +57,4 @@ class HeartbeatResponse(BaseModel):
expires_at: datetime
latest_version: str | None = None
waiter_domain: str | None = None
site_numeric_id: int | None = None # cloud DB pk — needed by Connect sync loops

View File

@@ -0,0 +1,25 @@
# Build menu-app
FROM node:20-alpine AS menu-builder
WORKDIR /app/menu-app
COPY menu-app/package.json .
RUN npm install
COPY menu-app/ .
ARG VITE_CLOUD_URL=
ENV VITE_CLOUD_URL=$VITE_CLOUD_URL
RUN npm run build
# Build manager-app
FROM node:20-alpine AS manager-builder
WORKDIR /app/manager-app
COPY manager-app/package.json .
RUN npm install
COPY manager-app/ .
ARG VITE_CLOUD_URL=
ENV VITE_CLOUD_URL=$VITE_CLOUD_URL
RUN npm run build
# Serve both with nginx
FROM nginx:alpine
COPY --from=menu-builder /app/menu-app/dist /usr/share/nginx/html/menu
COPY --from=manager-builder /app/manager-app/dist /usr/share/nginx/html/manage
COPY nginx-connect.conf /etc/nginx/conf.d/default.conf

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_CLOUD_URL=
ENV VITE_CLOUD_URL=$VITE_CLOUD_URL
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html/manage

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="el">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia Connect — Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "manager-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^1.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@@ -0,0 +1,24 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import SiteSelectorPage from './pages/SiteSelectorPage'
import DashboardPage from './pages/DashboardPage'
import IncomingOrdersPage from './pages/IncomingOrdersPage'
import OrderDetailPage from './pages/OrderDetailPage'
import OrderHistoryPage from './pages/OrderHistoryPage'
import RequireAuth from './components/RequireAuth'
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<SiteSelectorPage />} />
<Route path="/:siteId" element={<DashboardPage />} />
<Route path="/:siteId/orders" element={<IncomingOrdersPage />} />
<Route path="/:siteId/orders/history" element={<OrderHistoryPage />} />
<Route path="/:siteId/orders/:orderId" element={<OrderDetailPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,58 @@
import axios from 'axios'
const BASE = import.meta.env.VITE_CLOUD_URL || ''
const api = axios.create({ baseURL: BASE })
// Attach manager JWT from sessionStorage on every request
api.interceptors.request.use(config => {
const token = sessionStorage.getItem('manager_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Force logout on 401
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
sessionStorage.removeItem('manager_token')
window.location.href = '/manage/login'
}
return Promise.reject(err)
}
)
export default api
export async function managerLogin(email, password) {
const { data } = await api.post('/api/manager/login', { email, password })
return data // { access_token, token_type }
}
export async function fetchSites() {
const { data } = await api.get('/api/remote/sites')
return data
}
export async function fetchSnapshot(siteId) {
const { data } = await api.get(`/api/remote/sites/${siteId}/snapshot`)
return data
}
export async function fetchPendingOrders(siteId) {
const { data } = await api.get(`/api/remote/sites/${siteId}/orders/pending`)
return data
}
export async function fetchOrders(siteId, params = {}) {
const { data } = await api.get(`/api/remote/sites/${siteId}/orders`, { params })
return data
}
export async function updateOrderStatus(siteId, orderId, status, rejectionReason = null) {
await api.patch(`/api/remote/sites/${siteId}/orders/${orderId}/status`, {
status,
rejection_reason: rejectionReason,
})
}

View File

@@ -0,0 +1,41 @@
import { useNavigate, useParams } from 'react-router-dom'
import { Truck, UtensilsCrossed, Clock } from 'lucide-react'
import StatusBadge from './StatusBadge'
function timeAgo(iso) {
const diff = Math.floor((Date.now() - new Date(iso)) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
return `${Math.floor(diff / 3600)}h ago`
}
export default function OrderCard({ order }) {
const { siteId } = useParams()
const navigate = useNavigate()
return (
<button
onClick={() => navigate(`/${siteId}/orders/${order.id}`)}
className="w-full bg-white rounded-2xl shadow-sm p-4 text-left hover:shadow-md transition-shadow space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{order.order_type === 'delivery'
? <Truck size={16} className="text-indigo-500" />
: <UtensilsCrossed size={16} className="text-emerald-500" />}
<span className="font-bold text-slate-800 font-mono">{order.public_ref}</span>
</div>
<StatusBadge status={order.status} />
</div>
<p className="text-sm text-slate-600">{order.customer_name}</p>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-slate-800">{order.total?.toFixed(2)}</span>
<span className="text-xs text-slate-400 flex items-center gap-1">
<Clock size={12} /> {timeAgo(order.created_at)}
</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,6 @@
import { Navigate, Outlet } from 'react-router-dom'
export default function RequireAuth() {
const token = sessionStorage.getItem('manager_token')
return token ? <Outlet /> : <Navigate to="/login" replace />
}

View File

@@ -0,0 +1,9 @@
export default function StatCard({ label, value, sub, color = 'text-slate-800' }) {
return (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-1">
<p className="text-xs text-slate-400 font-medium uppercase tracking-wide">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value ?? '—'}</p>
{sub && <p className="text-xs text-slate-400">{sub}</p>}
</div>
)
}

View File

@@ -0,0 +1,27 @@
const STYLES = {
pending_acceptance: 'bg-amber-100 text-amber-700',
accepted: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-600',
preparing: 'bg-blue-100 text-blue-700',
ready: 'bg-teal-100 text-teal-700',
out_for_delivery: 'bg-indigo-100 text-indigo-700',
delivered: 'bg-slate-100 text-slate-500',
}
const LABELS = {
pending_acceptance: 'Pending',
accepted: 'Accepted',
rejected: 'Rejected',
preparing: 'Preparing',
ready: 'Ready',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
}
export default function StatusBadge({ status }) {
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${STYLES[status] || 'bg-slate-100 text-slate-500'}`}>
{LABELS[status] || status}
</span>
)
}

View File

@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }

View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/manage">
<App />
<Toaster position="top-center" />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, RefreshCw, Bell } from 'lucide-react'
import { fetchSnapshot, fetchPendingOrders } from '../api'
import StatCard from '../components/StatCard'
import OrderCard from '../components/OrderCard'
const POLL_MS = 15_000
export default function DashboardPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [snapshot, setSnapshot] = useState(null)
const [pending, setPending] = useState([])
const [lastUpdated, setLastUpdated] = useState(null)
const [loading, setLoading] = useState(true)
async function load() {
try {
const [snap, orders] = await Promise.all([
fetchSnapshot(siteId).catch(() => null),
fetchPendingOrders(siteId).catch(() => []),
])
setSnapshot(snap ? JSON.parse(snap.snapshot_json) : null)
setPending(orders)
setLastUpdated(new Date())
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
const timer = setInterval(load, POLL_MS)
return () => clearInterval(timer)
}, [siteId])
const stats = snapshot || {}
return (
<div className="min-h-screen bg-slate-50 pb-10">
{/* Header */}
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<button onClick={() => navigate('/')} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Dashboard</h1>
<button onClick={load} className="text-slate-400 hover:text-slate-700">
<RefreshCw size={18} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-5">
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard label="Open tables" value={stats.open_tables} />
<StatCard label="Revenue today" value={stats.today_revenue != null ? `${stats.today_revenue.toFixed(2)}` : null} color="text-emerald-600" />
<StatCard label="Orders today" value={stats.today_orders} />
<StatCard label="Online pending" value={stats.online_orders_pending} color={stats.online_orders_pending > 0 ? 'text-amber-600' : 'text-slate-800'} />
</div>
{lastUpdated && (
<p className="text-xs text-slate-400 text-right">
Updated {lastUpdated.toLocaleTimeString()}
</p>
)}
{/* Pending orders */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-bold text-slate-700 flex items-center gap-2">
<Bell size={16} className={pending.length ? 'text-amber-500' : 'text-slate-300'} />
Pending orders
{pending.length > 0 && (
<span className="bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5 rounded-full">
{pending.length}
</span>
)}
</h2>
<button
onClick={() => navigate(`/${siteId}/orders`)}
className="text-sky-500 text-sm font-medium hover:underline"
>
View all
</button>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : pending.length === 0 ? (
<div className="bg-white rounded-2xl p-8 text-center text-slate-400 text-sm shadow-sm">
No pending orders
</div>
) : (
pending.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { fetchPendingOrders } from '../api'
import OrderCard from '../components/OrderCard'
const POLL_MS = 15_000
export default function IncomingOrdersPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
async function load() {
try {
const data = await fetchPendingOrders(siteId)
setOrders(data)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
const timer = setInterval(load, POLL_MS)
return () => clearInterval(timer)
}, [siteId])
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Incoming Orders</h1>
<button onClick={load} className="text-slate-400 hover:text-slate-700">
<RefreshCw size={18} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : orders.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
<p className="font-medium">No pending orders</p>
<p className="text-sm mt-1">New orders will appear here automatically.</p>
</div>
) : (
orders.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { KeyRound } from 'lucide-react'
import { managerLogin } from '../api'
import toast from 'react-hot-toast'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
if (!email || !password) return
setLoading(true)
try {
const data = await managerLogin(email, password)
sessionStorage.setItem('manager_token', data.access_token)
navigate('/', { replace: true })
} catch (err) {
toast.error(err.response?.data?.detail || 'Invalid credentials')
} finally {
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-sm">
<div className="bg-white rounded-2xl shadow-xl border border-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center space-y-2">
<div className="flex justify-center mb-3">
<div className="w-12 h-12 bg-sky-500 rounded-xl flex items-center justify-center">
<KeyRound className="text-white" size={22} />
</div>
</div>
<h1 className="text-xl font-bold text-slate-800">Xenia Connect</h1>
<p className="text-sm text-slate-500">Manager Dashboard</p>
</div>
<div className="space-y-3">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
autoComplete="email"
required
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
required
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-sky-400 focus:ring-1 focus:ring-sky-100"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold transition-colors"
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
<p className="text-center text-xs text-slate-400 mt-4">Xenia POS · Remote Manager</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
import { fetchOrders, updateOrderStatus } from '../api'
import StatusBadge from '../components/StatusBadge'
import toast from 'react-hot-toast'
const NEXT_STATUSES = {
accepted: [{ value: 'preparing', label: 'Mark Preparing' }],
preparing: [
{ value: 'ready', label: 'Mark Ready' },
{ value: 'out_for_delivery', label: 'Out for Delivery' },
],
ready: [{ value: 'delivered', label: 'Mark Delivered' }],
out_for_delivery: [{ value: 'delivered', label: 'Mark Delivered' }],
}
export default function OrderDetailPage() {
const { siteId, orderId } = useParams()
const navigate = useNavigate()
const [order, setOrder] = useState(null)
const [loading, setLoading] = useState(true)
const [acting, setActing] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [showReject, setShowReject] = useState(false)
useEffect(() => {
fetchOrders(siteId)
.then(list => setOrder(list.find(o => String(o.id) === orderId) || null))
.finally(() => setLoading(false))
}, [siteId, orderId])
async function act(status, reason = null) {
setActing(true)
try {
await updateOrderStatus(siteId, orderId, status, reason)
toast.success(`Order ${status.replace('_', ' ')}`)
navigate(-1)
} catch (err) {
toast.error(err.response?.data?.detail || 'Action failed')
} finally {
setActing(false)
}
}
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
)
if (!order) return (
<div className="min-h-screen flex items-center justify-center text-slate-400">
Order not found.
</div>
)
const items = (() => { try { return JSON.parse(order.items_json || '[]') } catch { return [] } })()
const nextActions = NEXT_STATUSES[order.status] || []
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800 font-mono">{order.public_ref}</h1>
<div className="ml-auto"><StatusBadge status={order.status} /></div>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-5 space-y-4">
{/* Customer info */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
{order.order_type === 'delivery'
? <Truck size={15} className="text-indigo-500" />
: <UtensilsCrossed size={15} className="text-emerald-500" />}
{order.order_type === 'delivery' ? 'Delivery' : 'Dine In'}
</div>
<p className="font-bold text-slate-800">{order.customer_name}</p>
{order.customer_phone && <p className="text-sm text-slate-500">{order.customer_phone}</p>}
{order.customer_address && (
<p className="text-sm text-slate-500">{order.customer_address}</p>
)}
{order.customer_notes && (
<p className="text-sm text-slate-500 italic">"{order.customer_notes}"</p>
)}
</div>
{/* Items */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
{items.map((item, idx) => (
<div key={idx} className="flex justify-between text-sm">
<span className="text-slate-700">{item.quantity}× {item.name}</span>
<span className="text-slate-600 font-medium">{(item.unit_price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
<div className="flex justify-between text-sm text-slate-500">
<span>Subtotal</span><span>{order.subtotal?.toFixed(2)}</span>
</div>
{order.delivery_fee > 0 && (
<div className="flex justify-between text-sm text-slate-500">
<span>Delivery fee</span><span>{order.delivery_fee?.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-800">
<span>Total</span><span>{order.total?.toFixed(2)}</span>
</div>
</div>
</div>
{/* Actions */}
{order.status === 'pending_acceptance' && !showReject && (
<div className="flex gap-3">
<button
onClick={() => act('accepted')}
disabled={acting}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
>
Accept
</button>
<button
onClick={() => setShowReject(true)}
disabled={acting}
className="flex-1 bg-red-50 hover:bg-red-100 text-red-600 py-3.5 rounded-xl font-semibold transition-colors"
>
Reject
</button>
</div>
)}
{showReject && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-sm font-semibold text-slate-700">Rejection reason (optional)</p>
<input
type="text"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
placeholder="e.g. Kitchen closed, item unavailable…"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-red-300"
/>
<div className="flex gap-3">
<button
onClick={() => act('rejected', rejectReason || null)}
disabled={acting}
className="flex-1 bg-red-500 hover:bg-red-600 disabled:opacity-60 text-white py-3 rounded-xl font-semibold"
>
Confirm Rejection
</button>
<button onClick={() => setShowReject(false)} className="px-4 py-3 rounded-xl text-slate-500 hover:bg-slate-100">
Cancel
</button>
</div>
</div>
)}
{nextActions.map(({ value, label }) => (
<button
key={value}
onClick={() => act(value)}
disabled={acting}
className="w-full bg-sky-500 hover:bg-sky-600 disabled:opacity-60 text-white py-3.5 rounded-xl font-semibold transition-colors"
>
{label}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { fetchOrders } from '../api'
import OrderCard from '../components/OrderCard'
const STATUSES = [
{ value: '', label: 'All' },
{ value: 'pending_acceptance', label: 'Pending' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'preparing', label: 'Preparing' },
{ value: 'ready', label: 'Ready' },
{ value: 'out_for_delivery', label: 'Delivery' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'rejected', label: 'Rejected' },
]
export default function OrderHistoryPage() {
const { siteId } = useParams()
const navigate = useNavigate()
const [orders, setOrders] = useState([])
const [filter, setFilter] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetchOrders(siteId, filter ? { order_status: filter } : {})
.then(setOrders)
.finally(() => setLoading(false))
}, [siteId, filter])
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft size={20} />
</button>
<h1 className="font-bold text-slate-800">Order History</h1>
</div>
{/* Status filter tabs */}
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1">
{STATUSES.map(s => (
<button
key={s.value}
onClick={() => setFilter(s.value)}
className={`flex-shrink-0 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
filter === s.value
? 'bg-sky-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-5 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
</div>
) : orders.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center text-slate-400 shadow-sm">
No orders found.
</div>
) : (
orders.map(o => <OrderCard key={o.id} order={o} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Store, LogOut } from 'lucide-react'
import { fetchSites } from '../api'
export default function SiteSelectorPage() {
const [sites, setSites] = useState([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
fetchSites()
.then(data => {
setSites(data)
// Auto-navigate if only one site
if (data.length === 1) navigate(`/${data[0].id}`, { replace: true })
})
.finally(() => setLoading(false))
}, [])
function logout() {
sessionStorage.removeItem('manager_token')
navigate('/login', { replace: true })
}
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="max-w-md mx-auto space-y-5">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-800">Select Venue</h1>
<button onClick={logout} className="text-slate-400 hover:text-slate-600 flex items-center gap-1 text-sm">
<LogOut size={16} /> Log out
</button>
</div>
{sites.length === 0 ? (
<p className="text-slate-500 text-center py-12">No venues assigned to your account.</p>
) : (
<div className="space-y-3">
{sites.map(site => (
<button
key={site.id}
onClick={() => navigate(`/${site.id}`)}
className="w-full bg-white rounded-2xl p-5 shadow-sm hover:shadow-md transition-shadow text-left flex items-center gap-4"
>
<div className="w-12 h-12 bg-sky-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Store className="text-sky-600" size={22} />
</div>
<div>
<p className="font-bold text-slate-800">{site.name}</p>
<p className="text-xs text-slate-400 font-mono">{site.site_id}</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
},
},
plugins: [],
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/manage/',
server: { host: '0.0.0.0', port: 5201 },
build: { outDir: 'dist' },
})

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_CLOUD_URL=
ENV VITE_CLOUD_URL=$VITE_CLOUD_URL
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html/menu

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="el">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia Menu</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "menu-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^1.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@@ -0,0 +1,15 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import MenuPage from './pages/MenuPage'
import CartPage from './pages/CartPage'
import OrderConfirm from './pages/OrderConfirm'
export default function App() {
return (
<Routes>
<Route path="/:siteSlug" element={<MenuPage />} />
<Route path="/:siteSlug/order" element={<CartPage />} />
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,20 @@
import axios from 'axios'
const BASE = import.meta.env.VITE_CLOUD_URL || ''
const api = axios.create({ baseURL: BASE })
export async function fetchMenu(siteSlug) {
const { data } = await api.get(`/api/menu/${siteSlug}`)
return data
}
export async function submitOrder(siteSlug, body) {
const { data } = await api.post(`/api/orders/${siteSlug}`, body)
return data // { order_id, public_ref, status }
}
export async function fetchOrderStatus(publicRef) {
const { data } = await api.get(`/api/orders/status/${publicRef}`)
return data // { public_ref, status, rejection_reason }
}

View File

@@ -0,0 +1,25 @@
import { useNavigate } from 'react-router-dom'
import { ShoppingCart } from 'lucide-react'
export default function CartButton({ cart, siteSlug }) {
const navigate = useNavigate()
const count = cart.reduce((s, i) => s + i.quantity, 0)
const total = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
if (!count) return null
return (
<div className="fixed bottom-6 left-0 right-0 flex justify-center px-4 z-30">
<button
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
className="bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl px-6 py-3.5 shadow-lg flex items-center gap-3 w-full max-w-sm transition-colors"
>
<span className="bg-emerald-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
{count}
</span>
<span className="flex-1 font-semibold text-left">View order</span>
<span className="font-bold">{total.toFixed(2)}</span>
</button>
</div>
)
}

View File

@@ -0,0 +1,19 @@
export default function CategoryNav({ categories, active, onSelect }) {
return (
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1 scrollbar-hide">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => onSelect(cat.id)}
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
active === cat.id
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{cat.name}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,55 @@
export default function ProductCard({ product, onSelect }) {
const unavailable = !product.digital_available
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
return (
<button
onClick={unavailable ? undefined : onSelect}
className={`w-full bg-white rounded-2xl shadow-sm p-4 flex gap-4 text-left transition-shadow ${
unavailable ? 'opacity-60 cursor-default' : 'hover:shadow-md active:scale-[0.99]'
}`}
>
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-slate-800 leading-tight">
{product.digital_name || product.name}
</h3>
{unavailable && (
<span className="flex-shrink-0 text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full">
Out of stock
</span>
)}
</div>
{product.digital_description && (
<p className="text-xs text-slate-500 line-clamp-2">{product.digital_description}</p>
)}
<div className="flex items-baseline gap-2 pt-1">
<span className="font-bold text-emerald-600">{displayPrice.toFixed(2)}</span>
{hasDiscount && (
<>
<span className="text-xs text-slate-400 line-through">{basePrice.toFixed(2)}</span>
<span className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
</>
)}
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }

View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/menu">
<App />
<Toaster position="top-center" />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
import { submitOrder } from '../api'
import toast from 'react-hot-toast'
export default function CartPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const { state } = useLocation()
const cart = state?.cart || []
const [orderType, setOrderType] = useState('dine_in')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [address, setAddress] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const subtotal = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const deliveryFee = orderType === 'delivery' ? 2.0 : 0.0
const total = subtotal + deliveryFee
if (!cart.length) {
navigate(`/${siteSlug}`, { replace: true })
return null
}
async function handleSubmit(e) {
e.preventDefault()
if (!name.trim()) { toast.error('Please enter your name'); return }
if (orderType === 'delivery' && !address.trim()) {
toast.error('Please enter your delivery address')
return
}
setSubmitting(true)
try {
const result = await submitOrder(siteSlug, {
order_type: orderType,
customer_name: name.trim(),
customer_phone: phone.trim() || null,
customer_address: orderType === 'delivery' ? address.trim() : null,
customer_notes: notes.trim() || null,
items: cart.map(i => ({
product_id: i.product_id,
name: i.name,
quantity: i.quantity,
unit_price: i.unit_price,
options: i.options || [],
})),
subtotal,
delivery_fee: deliveryFee,
total,
})
navigate(`/${siteSlug}/confirm/${result.public_ref}`, { replace: true })
} catch (err) {
toast.error(err.response?.data?.detail || 'Failed to place order. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 shadow-sm">
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-500 hover:text-slate-800">
<ArrowLeft size={22} />
</button>
<h1 className="text-lg font-bold text-slate-800">Your Order</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="max-w-lg mx-auto px-4 py-5 space-y-5">
{/* Order type */}
<div className="bg-white rounded-2xl p-4 space-y-3 shadow-sm">
<p className="text-sm font-semibold text-slate-700">Order type</p>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed },
{ id: 'delivery', label: 'Delivery', Icon: Truck },
].map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setOrderType(id)}
className={`flex flex-col items-center gap-2 py-4 rounded-xl border-2 transition-colors ${
orderType === id
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
<Icon size={22} />
<span className="text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
{/* Cart summary */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
{cart.map((item, idx) => (
<div key={idx} className="flex justify-between text-sm">
<span className="text-slate-700">{item.quantity}× {item.name}</span>
<span className="text-slate-600 font-medium">{(item.unit_price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
<div className="flex justify-between text-sm text-slate-500">
<span>Subtotal</span><span>{subtotal.toFixed(2)}</span>
</div>
{deliveryFee > 0 && (
<div className="flex justify-between text-sm text-slate-500">
<span>Delivery fee</span><span>{deliveryFee.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-800">
<span>Total</span><span>{total.toFixed(2)}</span>
</div>
</div>
</div>
{/* Customer details */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-sm font-semibold text-slate-700">Your details</p>
{[
{ label: 'Name *', value: name, set: setName, type: 'text', placeholder: 'Full name' },
{ label: 'Phone', value: phone, set: setPhone, type: 'tel', placeholder: 'Optional' },
].map(({ label, value, set, type, placeholder }) => (
<div key={label}>
<label className="text-xs text-slate-500 font-medium">{label}</label>
<input
type={type}
value={value}
onChange={e => set(e.target.value)}
placeholder={placeholder}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100"
/>
</div>
))}
{orderType === 'delivery' && (
<div>
<label className="text-xs text-slate-500 font-medium">Delivery address *</label>
<textarea
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Street, number, city"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
)}
<div>
<label className="text-xs text-slate-500 font-medium">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Allergies, special requests…"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-4 rounded-xl font-bold text-base transition-colors"
>
{submitting ? 'Placing order…' : `Place order · €${total.toFixed(2)}`}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ShoppingCart, AlertCircle } from 'lucide-react'
import { fetchMenu } from '../api'
import CategoryNav from '../components/CategoryNav'
import ProductCard from '../components/ProductCard'
import CartButton from '../components/CartButton'
import ProductModal from './ProductModal'
export default function MenuPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const [menu, setMenu] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const [activeCat, setActiveCat] = useState(null)
const [cart, setCart] = useState([])
const [selected, setSelected] = useState(null) // product for modal
useEffect(() => {
fetchMenu(siteSlug)
.then(data => {
setMenu(data)
if (data.categories?.length) setActiveCat(data.categories[0].id)
})
.catch(() => setError('Menu not available. Please try again.'))
.finally(() => setLoading(false))
}, [siteSlug])
function addToCart(product, quantity, options) {
const price = product.digital_price ?? product.base_price
const discounted = product.digital_discount > 0 && !product.digital_price
? price * (1 - product.digital_discount / 100)
: price
setCart(prev => {
const key = product.id
const existing = prev.find(i => i.product_id === key)
if (existing) {
return prev.map(i => i.product_id === key
? { ...i, quantity: i.quantity + quantity }
: i)
}
return [...prev, {
product_id: product.id,
name: product.digital_name || product.name,
quantity,
unit_price: discounted,
options,
}]
})
setSelected(null)
}
const activeCategory = menu?.categories?.find(c => c.id === activeCat)
const cartCount = cart.reduce((s, i) => s + i.quantity, 0)
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
</div>
)
if (error) return (
<div className="min-h-screen flex flex-col items-center justify-center gap-3 p-8 text-center">
<AlertCircle className="text-red-400" size={40} />
<p className="text-slate-600">{error}</p>
</div>
)
return (
<div className="min-h-screen bg-slate-50 pb-28">
{/* Header */}
<div className="sticky top-0 z-20 bg-white border-b border-slate-100 shadow-sm">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<h1 className="text-lg font-bold text-slate-800">Menu</h1>
{cartCount > 0 && (
<button
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
className="flex items-center gap-2 bg-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold"
>
<ShoppingCart size={16} />
{cartCount} items
</button>
)}
</div>
<CategoryNav
categories={menu.categories}
active={activeCat}
onSelect={setActiveCat}
/>
</div>
{/* Products */}
<div className="max-w-2xl mx-auto px-4 pt-4 space-y-3">
{activeCategory?.products.map(p => (
<ProductCard
key={p.id}
product={p}
onSelect={() => setSelected(p)}
/>
))}
{!activeCategory?.products.length && (
<p className="text-center text-slate-400 py-12">No items in this category.</p>
)}
</div>
<CartButton cart={cart} siteSlug={siteSlug} />
{selected && (
<ProductModal
product={selected}
onClose={() => setSelected(null)}
onAdd={addToCart}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } from 'lucide-react'
import { fetchOrderStatus } from '../api'
const STATUS_CONFIG = {
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' },
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
}
const TERMINAL = new Set(['rejected', 'delivered'])
const POLL_MS = 10_000
export default function OrderConfirm() {
const { ref } = useParams()
const [orderStatus, setOrderStatus] = useState(null)
const [rejectionReason, setRejectionReason] = useState(null)
const [error, setError] = useState(false)
useEffect(() => {
let timer
async function poll() {
try {
const data = await fetchOrderStatus(ref)
setOrderStatus(data.status)
setRejectionReason(data.rejection_reason)
if (!TERMINAL.has(data.status)) {
timer = setTimeout(poll, POLL_MS)
}
} catch {
setError(true)
}
}
poll()
return () => clearTimeout(timer)
}, [ref])
const cfg = STATUS_CONFIG[orderStatus] || STATUS_CONFIG['pending_acceptance']
const { label, Icon, color, bg } = cfg
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
<Icon className={color} size={40} />
</div>
<div className="space-y-1">
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
<h1 className="text-2xl font-bold text-slate-800">
{error ? 'Could not load status' : label}
</h1>
{rejectionReason && (
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
)}
</div>
{!TERMINAL.has(orderStatus) && !error && (
<p className="text-xs text-slate-400">
We'll update this page automatically. Keep it open.
</p>
)}
{orderStatus === 'rejected' && (
<p className="text-sm text-slate-500">
We're sorry we couldn't take your order this time. Please try again or visit us in person.
</p>
)}
{orderStatus === 'delivered' && (
<p className="text-sm text-slate-500">Thank you for your order! Enjoy your meal.</p>
)}
{!TERMINAL.has(orderStatus) && !error && (
<div className="flex justify-center">
<div className="w-5 h-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { X, Plus, Minus } from 'lucide-react'
export default function ProductModal({ product, onClose, onAdd }) {
const [quantity, setQuantity] = useState(1)
const [selectedOptions, setSelectedOptions] = useState([])
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
function toggleOption(opt) {
setSelectedOptions(prev =>
prev.find(o => o.id === opt.id)
? prev.filter(o => o.id !== opt.id)
: [...prev, opt]
)
}
const optionsTotal = selectedOptions.reduce((s, o) => s + (o.price || 0), 0)
const lineTotal = (displayPrice + optionsTotal) * quantity
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[90vh] overflow-y-auto">
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-full h-48 object-cover rounded-t-2xl sm:rounded-t-2xl"
/>
)}
<button
onClick={onClose}
className="absolute top-3 right-3 bg-white/90 rounded-full p-1.5 shadow"
>
<X size={18} />
</button>
<div className="p-5 space-y-4">
<div>
<h2 className="text-xl font-bold text-slate-800">
{product.digital_name || product.name}
</h2>
{product.digital_description && (
<p className="text-sm text-slate-500 mt-1">{product.digital_description}</p>
)}
</div>
{/* Price */}
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-emerald-600">
{displayPrice.toFixed(2)}
</span>
{hasDiscount && (
<span className="text-sm text-slate-400 line-through">
{basePrice.toFixed(2)}
</span>
)}
{hasDiscount && (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
)}
</div>
{/* Quick options */}
{product.quick_options?.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-semibold text-slate-700">Options</p>
<div className="flex flex-wrap gap-2">
{product.quick_options.map(opt => {
const active = selectedOptions.find(o => o.id === opt.id)
return (
<button
key={opt.id}
onClick={() => toggleOption(opt)}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-emerald-500 text-white border-emerald-500'
: 'bg-white text-slate-700 border-slate-200 hover:border-emerald-300'
}`}
>
{opt.name}{opt.price > 0 ? ` +€${opt.price.toFixed(2)}` : ''}
</button>
)
})}
</div>
</div>
)}
{/* Quantity */}
<div className="flex items-center gap-4">
<span className="text-sm font-semibold text-slate-700">Quantity</span>
<div className="flex items-center gap-3">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Minus size={16} />
</button>
<span className="text-lg font-bold w-6 text-center">{quantity}</span>
<button
onClick={() => setQuantity(q => q + 1)}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Plus size={16} />
</button>
</div>
</div>
{/* Add to cart */}
{product.digital_available ? (
<button
onClick={() => onAdd(product, quantity, selectedOptions)}
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3.5 rounded-xl font-semibold flex items-center justify-between px-5 transition-colors"
>
<span>Add to order</span>
<span>{lineTotal.toFixed(2)}</span>
</button>
) : (
<div className="w-full bg-slate-100 text-slate-400 py-3.5 rounded-xl font-semibold text-center">
Currently unavailable
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
},
},
plugins: [],
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/menu/',
server: { host: '0.0.0.0', port: 5200 },
build: { outDir: 'dist' },
})

View File

@@ -0,0 +1,21 @@
server {
listen 80;
# Public menu SPA
location /menu/ {
alias /usr/share/nginx/html/menu/;
try_files $uri $uri/ /menu/index.html;
}
# Remote manager SPA
location /manage/ {
alias /usr/share/nginx/html/manage/;
try_files $uri $uri/ /manage/index.html;
}
# Health check
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}

View File

@@ -17,5 +17,16 @@ services:
ports:
- "5175:80"
restart: unless-stopped
depends_on:
- cloud_backend
connect_frontend:
build:
context: ./connect_frontend
args:
VITE_CLOUD_URL: ${VITE_CLOUD_URL}
ports:
- "3100:80"
restart: unless-stopped
depends_on:
- cloud_backend

21
nginx-connect.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
# Public menu SPA
location /menu/ {
alias /usr/share/nginx/html/menu/;
try_files $uri $uri/ /menu/index.html;
}
# Remote manager SPA
location /manage/ {
alias /usr/share/nginx/html/manage/;
try_files $uri $uri/ /manage/index.html;
}
# Health check
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}