Compare commits

..

16 Commits

Author SHA1 Message Date
0cad6a76d3 change the QR Code URl. Just a quick fix 2026-06-01 20:06:46 +03:00
a6f759bf49 fix(menu-app): 6 UI adjustments
1. Font consistency: add Noto Sans as fallback for both display and body
   fonts so Greek characters render in the same visual weight as Latin
2. Category panel gradients: reduce top opacity 0.85 → 0.65 (20% less)
3. Mobile full-width: remove max-w cap on mobile; sm:max-w-[960px] on
   desktop (2× wider than before); shadow only on sm+ breakpoint
4. Smaller Add button (h-9→h-8, text-13→12) and price (22px→18px)
5. More spacing between description and footer hairline (pt-3 mt-3)
6. Square hero image in product detail sheet (aspect-square instead of h-44)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:23:40 +03:00
ffaeab136d feat(menu-app): full redesign to Olive & Thyme design spec
Rebuild the public-facing digital menu from the design handoff bundle.
Matches high-fidelity spec: Bricolage Grotesque + Hanken Grotesk fonts,
cream/green/gold palette, per-category tinted panels, product cards with
gradient art, tag icon badges, bottom-sheet product detail, search overlay,
cart → checkout bottom-sheet flow posting to real API, floating cart button
with bump animation, and restyled order-confirm page.

- New: src/components/primitives.jsx (DishArt, Badge, DietChip, TagIcons,
  Price, DiscountFlag, Stepper, price helpers)
- Rewrite: MenuPage.jsx — all screens in one component tree, API-driven,
  lang toggle (EN/GR) persisted to localStorage, scroll-spy category bar
- Rewrite: OrderConfirm.jsx — design-system styling, brand colors
- Update: App.jsx — remove /order route (cart flow is now a bottom sheet)
- Update: tailwind.config.js — font families, brand tokens, animations
- Update: index.html — Google Fonts for Bricolage Grotesque + Hanken Grotesk
- Delete: CartPage, ProductModal, ProductCard, CategoryNav, CartButton

Cart checkout POSTs to real submitOrder API and redirects to /confirm/:ref
for live order status polling. Restaurant info falls back to hardcoded
placeholder until backend includes it in the fetchMenu response (see
REWORK_REVISIT.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:55:52 +03:00
17fb3a2589 fix(sysadmin): use site_id UUID string instead of integer PK throughout manager endpoints
The sysadmin panel URL uses site.site_id (UUID string) not site.id (int PK).
All manager account endpoints were querying/expecting the integer PK — none matched.

- GET /by-site/{site_id}: param type str, filter by Site.site_id
- POST /register: site_ids type list[str], query Site.site_id.in_()
- DELETE /site-access: site_id type str, query Site.site_id
- schemas/manager.py: ManagerRegisterRequest.site_ids list[int] → list[str]
- SiteDetailPage.jsx: remove Number() casts on siteId in add/remove calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:45:10 +03:00
70c7b9564b feat(manager-app): Part 5 — show snapshot_taken_at freshness in DashboardPage
Adds a "Site data: X min ago" line derived from snapshot.snapshot_taken_at
so managers can see how fresh the stats snapshot is.

All other Part 5 items (menu-app api.js, OrderConfirm polling, manager-app
api.js, IncomingOrdersPage 15s poll + OrderCard actions) were already fully
implemented in the existing scaffolding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:10:08 +03:00
82b853369b feat(sysadmin): Part 4 — QR code section in SiteDetailPage
- Install qrcode.react in sysadmin_panel
- Add QR Codes section above Remote Managers with two codes side by side:
  Menu QR (https://yourdomain.com/menu/{site_id}) and
  Order QR (https://yourdomain.com/menu/{site_id}/order)
- Each card shows the URL as copyable text and a Download PNG button
- Uses site.site_id as the slug (no slug field exists on the Site model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:07:28 +03:00
ac58d150ea feat(sysadmin): Part 3 — Remote Managers section in SiteDetailPage
Backend (manager_auth.py):
- register: upsert instead of 409 — if email exists, just add site access
- GET /api/manager/by-site/{site_id}: list managers linked to a site (admin auth)
- DELETE /api/manager/site-access: remove a manager's access to a site (admin auth)

Sysadmin API client:
- getManagersBySite, addManagerToSite, removeManagerSiteAccess

SiteDetailPage.jsx:
- Remote Managers section: lists all linked managers with email, name, active badge
- Add Manager modal: email + full name + password form (password ignored if account exists)
- Remove access button with optimistic removal from list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:04:28 +03:00
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
60 changed files with 6425 additions and 13 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,150 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount, manager_site_access
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),
):
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
sites = db.query(Site).filter(Site.site_id.in_(body.site_ids)).all()
if existing:
# Email already exists — just add the new site access links, don't recreate the account
for site in sites:
if site not in existing.sites:
existing.sites.append(site)
db.commit()
db.refresh(existing)
return existing
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))
# ── Admin-only: list managers for a site ─────────────────────────────────────
class ManagerBySiteOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
model_config = {"from_attributes": True}
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
def get_managers_by_site(
site_id: str,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site.manager_accounts
# ── Admin-only: remove a manager's access to a site ──────────────────────────
class SiteAccessRemoveRequest(BaseModel):
manager_id: int
site_id: str # site_id UUID string, not integer PK
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
def remove_manager_site_access(
body: SiteAccessRemoveRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
site = db.query(Site).filter(Site.site_id == body.site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site in manager.sites:
manager.sites.remove(site)
db.commit()

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[str] = [] # site_id UUID strings, not integer PKs
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,116 @@
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">
Refreshed {lastUpdated.toLocaleTimeString()}
</p>
)}
{stats.snapshot_taken_at && (() => {
const diffMin = Math.round((Date.now() - new Date(stats.snapshot_taken_at).getTime()) / 60_000)
const label = diffMin < 1 ? 'just now' : `${diffMin} min ago`
return (
<p className="text-xs text-slate-400 text-right -mt-3">
Site data: {label}
</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="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Hanken+Grotesk:wght@400;500;600;700&family=Noto+Sans: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>

3087
connect_frontend/menu-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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,13 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import MenuPage from './pages/MenuPage'
import OrderConfirm from './pages/OrderConfirm'
export default function App() {
return (
<Routes>
<Route path="/:siteSlug" element={<MenuPage />} />
<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,209 @@
import {
Leaf, Sprout, Wheat, Flame, Star, ChefHat,
Minus, Plus,
} from 'lucide-react'
// ── Money helpers ────────────────────────────────────────────────────────────
export function eur(n) {
return '€' + Number(n).toFixed(2)
}
export function discountedPrice(product) {
const base = product.digital_price ?? product.base_price ?? product.price ?? 0
const pct = product.digital_discount ?? product.discountPct ?? 0
if (!pct) return base
return Math.round(base * (1 - pct / 100) * 100) / 100
}
export function basePrice(product) {
return product.digital_price ?? product.base_price ?? product.price ?? 0
}
export function hasDiscount(product) {
const pct = product.digital_discount ?? product.discountPct ?? 0
return pct > 0
}
export function discountPct(product) {
return product.digital_discount ?? product.discountPct ?? 0
}
// ── Placeholder gradient art ─────────────────────────────────────────────────
export function DishArt({ product, category, size = 'card' }) {
const hue = category?.hue ?? 40
const GlyphIcon = category?.GlyphIcon ?? null
const id = product?.id ?? 'x'
let seed = 0
for (let i = 0; i < id.length; i++) seed += id.charCodeAt(i)
const lift = (seed % 5) - 2
const c1 = `hsl(${hue} 34% ${90 + lift}%)`
const c2 = `hsl(${hue} 30% ${80 + lift}%)`
const glyphColor = `hsl(${hue} 32% 42%)`
const firstName = product?.digital_name || product?.name || '?'
const letter = typeof firstName === 'object' ? (firstName.en?.[0] ?? '?') : (firstName[0] ?? '?')
const sizeClass =
size === 'hero'
? 'self-stretch min-h-[100px] w-[100px]'
: size === 'sm'
? 'h-16 w-16'
: 'h-[92px] w-[92px]'
const iconClass = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'
return (
<div
className={`relative flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-[13px]`}
style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}
>
<span
className="absolute -right-2 -top-3 font-display text-[56px] leading-none opacity-[0.14] select-none"
style={{ color: glyphColor }}
>
{letter}
</span>
{GlyphIcon && (
<GlyphIcon
className={iconClass}
style={{ color: glyphColor, opacity: 0.62 }}
strokeWidth={1.4}
/>
)}
</div>
)
}
// ── Badge (Popular / Chef's pick) ────────────────────────────────────────────
export function Badge({ kind, t }) {
if (kind === 'popular') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#f4ecd8] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#a9842f] ring-1 ring-inset ring-[#e4d4a8]">
<Flame className="h-3 w-3" strokeWidth={2.2} />
{t?.popular ?? 'Popular'}
</span>
)
}
if (kind === 'chefs') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#2d3b2d] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#f0e9d6]">
<ChefHat className="h-3 w-3" strokeWidth={2} />
{t?.chefs ?? "Chef's pick"}
</span>
)
}
return null
}
// ── Dietary chips (with text labels) ────────────────────────────────────────
const DIET_STYLE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#c9e2cb' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#d8e2bd' },
'gluten-free': { Icon: Wheat, fg: '#a9842f', bg: '#f5edd8', ring: '#e6d6a6' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f7e6dc', ring: '#eccab3' },
}
export function DietChip({ tag, t }) {
const s = DIET_STYLE[tag]
if (!s) return null
const { Icon } = s
const label = t?.dietary?.[tag] ?? tag
return (
<span
className="inline-flex items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2} />
{label}
</span>
)
}
// ── Compact tag icon badges (card title row) ─────────────────────────────────
const TAG_BADGE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#bcdcc0' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#cfe0b0' },
'gluten-free': { Icon: Wheat, fg: '#9a7726', bg: '#f6edd6', ring: '#e6d49e' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f8e6da', ring: '#eec4ac' },
}
const PRIORITY_BADGE = {
popular: { Icon: Star, fg: '#a9842f', bg: '#f6edd6', ring: '#e6d49e' },
chefs: { Icon: ChefHat, fg: '#f0e9d6', bg: '#2d3b2d', ring: '#2d3b2d' },
}
export function TagIcons({ product }) {
const items = []
const badge = product.badge ?? product.digital_badge
if (badge && PRIORITY_BADGE[badge]) items.push(PRIORITY_BADGE[badge])
const tags = product.tags ?? product.digital_tags ?? []
tags.forEach(tag => { if (TAG_BADGE[tag]) items.push(TAG_BADGE[tag]) })
const shown = items.slice(0, 3)
if (!shown.length) return null
return (
<div className="flex shrink-0 items-center gap-1 pt-[3px]">
{shown.map((s, i) => {
const { Icon } = s
return (
<span
key={i}
className="flex h-[19px] w-[19px] items-center justify-center rounded-full ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2.2} />
</span>
)
})}
</div>
)
}
// ── Price display ─────────────────────────────────────────────────────────────
export function Price({ product, large }) {
const base = basePrice(product)
const now = discountedPrice(product)
const discounted = hasDiscount(product)
return (
<div className="flex items-baseline gap-1.5">
{discounted && (
<span className="font-display text-[13px] text-[#b3aa97] line-through">{eur(base)}</span>
)}
<span
className={`font-display ${large ? 'text-[18px]' : 'text-[16px]'} font-semibold ${discounted ? 'text-[#c2602f]' : 'text-[#2d3b2d]'}`}
>
{eur(now)}
</span>
</div>
)
}
// ── Discount flag ─────────────────────────────────────────────────────────────
export function DiscountFlag({ product, t }) {
const pct = discountPct(product)
if (!pct) return null
return (
<span className="inline-flex items-center rounded-md bg-[#c2602f] px-1.5 py-[2px] font-sans text-[10px] font-bold tracking-[0.05em] text-white">
{pct}% {t?.off ?? 'OFF'}
</span>
)
}
// ── Quantity stepper ─────────────────────────────────────────────────────────
export function Stepper({ qty, onInc, onDec }) {
return (
<div className="flex items-center gap-3 rounded-full bg-[#f3efe5] p-1 ring-1 ring-inset ring-[#e8e1d1]">
<button
onClick={onDec}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#2d3b2d] shadow-sm ring-1 ring-[#e8e1d1] transition active:scale-90"
>
<Minus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
<span className="min-w-[16px] text-center font-display text-[16px] font-semibold tabular-nums text-[#2d3b2d]">
{qty}
</span>
<button
onClick={onInc}
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#2d3b2d] text-white shadow-sm transition active:scale-90"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
</div>
)
}

View File

@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Hanken Grotesk', system-ui, sans-serif;
background: radial-gradient(ellipse at top, #f3eedf 0%, #ece4d2 50%, #e6ddc8 100%);
min-height: 100dvh;
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.font-display { font-family: 'Bricolage Grotesque', serif; }
}

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,922 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
MapPin, Search, Leaf, X, Plus, ShoppingBag,
ChevronLeft, ArrowRight, Send, Loader2, Info,
Armchair, Check, SearchX, UtensilsCrossed, AlertCircle,
Soup, Salad, Wheat, IceCream2, Wine,
} from 'lucide-react'
import { fetchMenu, submitOrder } from '../api'
import {
DishArt, Badge, DietChip, TagIcons, Price, DiscountFlag, Stepper,
eur, discountedPrice, discountPct,
} from '../components/primitives'
// TODO: Replace with real data from API once backend includes restaurant info
const RESTAURANT_FALLBACK = {
name: 'Our Menu',
tagline: { en: 'Kitchen & Bar', gr: 'Κουζίνα & Μπαρ' },
blurb: { en: 'Fresh seasonal plates, served with care.', gr: 'Εποχιακά πιάτα, με αγάπη.' },
hours: { en: 'Open today · 12:00 23:30', gr: 'Ανοιχτά σήμερα · 12:00 23:30' },
location: { en: '', gr: '' },
}
// Category glyph icons — mapped by category id or index
const GLYPH_BY_ID = { starters: Soup, salads: Salad, mains: UtensilsCrossed, sides: Wheat, desserts: IceCream2, drinks: Wine }
const GLYPH_FALLBACK = [Soup, Salad, UtensilsCrossed, Wheat, IceCream2, Wine]
const CATEGORY_HUES = [96, 78, 18, 40, 340, 176]
// Allergen label fallbacks
const ALLERGEN_EN = { gluten: 'gluten', dairy: 'dairy', egg: 'egg', fish: 'fish', shellfish: 'shellfish', nuts: 'nuts', soy: 'soy', sesame: 'sesame', sulphites: 'sulphites' }
const I18N = {
en: {
searchPlaceholder: 'Search the menu…',
add: 'Add', back: 'Back', total: 'Total',
yourOrder: 'Your order', emptyCart: 'Your cart is empty',
emptyCartSub: 'Add a few dishes to get started.',
continue: 'Continue', placeOrder: 'Place order', sending: 'Sending…',
orderPlaced: 'Order placed!', orderPlacedSub: 'Your order has been sent to the kitchen.',
newOrder: 'Back to menu', table: 'Table no.', name: 'Your name',
namePh: 'e.g. Alex', tablePh: 'e.g. 12', orderSummary: 'Order summary',
contains: 'Contains', ingredients: 'Ingredients', noResults: 'No dishes found',
noResultsSub: 'Try a different search.',
popular: 'Popular', chefs: "Chef's pick", off: 'OFF',
dietary: { vegan: 'Vegan', vegetarian: 'Vegetarian', 'gluten-free': 'Gluten-free', spicy: 'Spicy' },
},
gr: {
searchPlaceholder: 'Αναζήτηση στο μενού…',
add: 'Προσθήκη', back: 'Πίσω', total: 'Σύνολο',
yourOrder: 'Η παραγγελία σου', emptyCart: 'Το καλάθι σου είναι άδειο',
emptyCartSub: 'Πρόσθεσε μερικά πιάτα για να ξεκινήσεις.',
continue: 'Συνέχεια', placeOrder: 'Αποστολή παραγγελίας', sending: 'Αποστολή…',
orderPlaced: 'Η παραγγελία στάλθηκε!', orderPlacedSub: 'Η παραγγελία σου εστάλη στην κουζίνα.',
newOrder: 'Πίσω στο μενού', table: 'Τραπέζι', name: 'Το όνομά σου',
namePh: 'π.χ. Αλέξης', tablePh: 'π.χ. 12', orderSummary: 'Σύνοψη παραγγελίας',
contains: 'Περιέχει', ingredients: 'Συστατικά', noResults: 'Δεν βρέθηκαν πιάτα',
noResultsSub: 'Δοκίμασε διαφορετική αναζήτηση.',
popular: 'Δημοφιλές', chefs: 'Επιλογή Σεφ', off: 'ΕΚΠΤΩΣΗ',
dietary: { vegan: 'Vegan', vegetarian: 'Χορτοφαγικό', 'gluten-free': 'Χωρίς γλουτένη', spicy: 'Πικάντικο' },
},
}
// ── Normalise a backend product to the shape the UI expects ──────────────────
function normaliseProduct(p, catId) {
return {
...p,
id: String(p.id),
cat: catId,
name: p.digital_name || p.name || '',
desc: p.digital_description || p.description || '',
price: p.digital_price ?? p.base_price ?? 0,
badge: p.digital_badge || p.badge || null,
tags: p.digital_tags || p.tags || [],
allergens: p.allergens || [],
ingredients: p.ingredients || null,
discountPct: p.digital_discount || p.discountPct || 0,
image_url: p.digital_image_url || p.image_url || null,
digital_available: p.digital_available !== false,
}
}
function normaliseCategories(rawCats) {
return rawCats.map((cat, idx) => {
const id = cat.id ? String(cat.id) : `cat-${idx}`
return {
...cat,
id,
hue: cat.hue ?? CATEGORY_HUES[idx % CATEGORY_HUES.length],
GlyphIcon: GLYPH_BY_ID[id] ?? GLYPH_FALLBACK[idx % GLYPH_FALLBACK.length],
products: (cat.products || []).map(p => normaliseProduct(p, id)),
}
})
}
// ── Bottom Sheet shell ────────────────────────────────────────────────────────
function Sheet({ open, onClose, children, maxH = '88vh' }) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<div
className="absolute inset-0 animate-fade bg-[#2d2a1f]/40 backdrop-blur-[2px]"
onClick={onClose}
/>
<div
className="relative z-10 w-full sm:max-w-[960px] animate-slideup overflow-hidden rounded-t-[24px] bg-[#faf7f0] shadow-[0_-12px_40px_-12px_rgba(45,42,31,0.4)]"
style={{ maxHeight: maxH }}
>
{children}
</div>
</div>
)
}
function SheetHandle() {
return (
<div className="flex justify-center pt-2.5">
<div className="h-1 w-10 rounded-full bg-[#ddd5c0]" />
</div>
)
}
// ── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ lang, setLang, restaurant }) {
const r = { ...RESTAURANT_FALLBACK, ...restaurant }
return (
<header className="relative px-6 pt-7 pb-6 text-center">
{/* Language toggle */}
<div className="absolute right-5 top-6">
<div className="flex items-center rounded-full bg-white/70 p-0.5 text-[11px] font-semibold ring-1 ring-[#e3dcc9] backdrop-blur">
{['en', 'gr'].map(l => (
<button
key={l}
onClick={() => setLang(l)}
className={`rounded-full px-2.5 py-1 uppercase tracking-wider transition ${
lang === l ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'text-[#8a8266]'
}`}
>
{l === 'en' ? 'EN' : 'ΕΛ'}
</button>
))}
</div>
</div>
{r.location?.[lang] && (
<div className="mx-auto inline-flex items-center gap-1.5 rounded-full bg-white/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#8a7f5e] ring-1 ring-[#e8e1d1]">
<MapPin className="h-3 w-3" strokeWidth={2} />
{r.location[lang]}
</div>
)}
<h1 className="mt-4 font-display text-[42px] font-semibold leading-[0.95] tracking-tight text-[#2d3b2d]">
{r.name}
</h1>
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
{r.tagline?.[lang] ?? r.tagline ?? ''}
</div>
{/* Ornament */}
<div className="mx-auto my-4 flex w-40 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
<p className="mx-auto max-w-[300px] text-[13px] leading-relaxed text-[#7d7660]">
{r.blurb?.[lang] ?? r.blurb ?? ''}
</p>
{r.hours?.[lang] && (
<div className="mt-2.5 inline-flex items-center gap-1.5 text-[12px] font-medium text-[#3f7d4e]">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#3f7d4e] opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[#3f7d4e]" />
</span>
{r.hours[lang]}
</div>
)}
</header>
)
}
// ── Category Bar ──────────────────────────────────────────────────────────────
function CategoryBar({ categories, active, onPick, onSearch, lang }) {
const [scrolled, setScrolled] = useState(false)
const railRef = useRef(null)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 220)
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
const rail = railRef.current
if (!rail) return
const el = rail.querySelector(`[data-pill="${active}"]`)
if (el) {
const target = el.offsetLeft - rail.clientWidth / 2 + el.clientWidth / 2
rail.scrollTo({ left: target, behavior: 'smooth' })
}
}, [active])
return (
<div className={`sticky top-0 z-30 bg-[#faf7f0]/95 backdrop-blur transition-shadow ${scrolled ? 'shadow-[0_6px_20px_-12px_rgba(45,59,45,0.35)]' : ''}`}>
<div className="flex items-center gap-2 px-3 py-2.5">
<button
onClick={onSearch}
aria-label="Search"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-[#2d3b2d] ring-1 ring-[#e8e1d1] transition active:scale-90"
>
<Search className="h-[18px] w-[18px]" strokeWidth={1.9} />
</button>
<div ref={railRef} className="flex gap-1.5 overflow-x-auto no-scrollbar py-[3px]">
{categories.map(cat => {
const on = cat.id === active
const label = typeof cat.name === 'object' ? (cat.name[lang] ?? cat.name.en) : cat.name
return (
<button
key={cat.id}
data-pill={cat.id}
onClick={() => onPick(cat.id)}
className={`whitespace-nowrap rounded-full px-3.5 py-1.5 text-[13px] font-medium transition ${
on
? 'bg-[#2d3b2d] text-[#f0e9d6] shadow-sm'
: 'bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]'
}`}
>
{label}
</button>
)
})}
</div>
</div>
</div>
)
}
// ── Product Card ──────────────────────────────────────────────────────────────
function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name
const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc
const unavailable = product.digital_available === false
return (
<div
onClick={unavailable ? undefined : () => onOpen(product)}
className={`group overflow-hidden rounded-[20px] bg-[#fcfbf7] ring-1 ring-[#e7e1d1] shadow-card transition hover:shadow-card-hover active:scale-[0.992] ${unavailable ? 'opacity-50' : 'cursor-pointer'}`}
>
<div className="flex items-stretch gap-3.5 p-3.5">
{/* Left: photo or placeholder art */}
{product.image_url ? (
<img
src={product.image_url}
alt={name}
className="w-[100px] min-h-[100px] self-stretch shrink-0 rounded-[13px] object-cover"
/>
) : (
<DishArt product={product} category={category} size="hero" />
)}
{/* Right: content column */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start gap-2">
<h3 className="min-w-0 flex-1 font-display text-[19px] font-semibold leading-[1.12] tracking-[-0.01em] text-[#2d3b2d]">
{name}
</h3>
<TagIcons product={product} />
</div>
<p className="mt-1.5 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-[#857e69]">
{desc}
</p>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[#ece4d2] pt-3 mt-3">
<div className="flex items-center gap-2">
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
className="flex h-8 items-center gap-1 rounded-full bg-[#2d3b2d] pl-2.5 pr-3 text-[12px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-95 hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
</button>
</div>
</div>
</div>
</div>
)
}
// ── Menu Section ──────────────────────────────────────────────────────────────
function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
const { hue, products } = category
const label = typeof category.name === 'object' ? (category.name[lang] ?? category.name.en) : category.name
return (
<section ref={sectionRef} data-section={category.id} className="scroll-mt-[64px] px-3 pt-3">
<div
className="rounded-[22px] px-3 pb-3 pt-2.5"
style={{ background: `linear-gradient(180deg, hsl(${hue} 44% 90% / 0.65) 0%, hsl(${hue} 40% 90% / 0) 62%)` }}
>
<div className="mb-2.5 flex items-baseline justify-between px-2 pt-1">
<h2 className="font-sans text-[21px] font-semibold tracking-[-0.01em] text-[#2a2a2a]">{label}</h2>
<span className="font-sans text-[15px] font-medium tabular-nums text-[#2a2a2a]/45">{products.length}</span>
</div>
<div className="flex flex-col gap-3">
{products.map(p => (
<ProductCard
key={p.id}
product={p}
category={category}
lang={lang}
t={t}
onOpen={onOpen}
onAdd={onAdd}
qty={cart[p.id] || 0}
/>
))}
</div>
</div>
</section>
)
}
// ── Product Detail Sheet ──────────────────────────────────────────────────────
function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec }) {
if (!product) return null
const hue = category?.hue ?? 40
const GlyphIcon = category?.GlyphIcon ?? UtensilsCrossed
const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name
const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc
const ingredients = product.ingredients
? (typeof product.ingredients === 'object' && !Array.isArray(product.ingredients)
? (product.ingredients[lang] ?? product.ingredients.en ?? [])
: product.ingredients)
: []
const allergens = product.allergens ?? []
const badge = product.badge ?? product.digital_badge
const tags = product.tags ?? product.digital_tags ?? []
return (
<Sheet open={!!product} onClose={onClose}>
<SheetHandle />
<button
onClick={onClose}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#6d6a59] ring-1 ring-[#e8e1d1]"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
<div className="max-h-[82vh] overflow-y-auto px-5 pb-5">
{/* Hero art */}
{product.image_url ? (
<div className="mt-2 aspect-square w-full overflow-hidden rounded-2xl">
<img
src={product.image_url}
alt={name}
className="h-full w-full object-cover"
/>
</div>
) : (
<div
className="mt-2 aspect-square w-full flex items-center justify-center overflow-hidden rounded-2xl"
style={{ background: `linear-gradient(135deg, hsl(${hue} 34% 90%), hsl(${hue} 30% 80%))` }}
>
<GlyphIcon
className="h-16 w-16"
style={{ color: `hsl(${hue} 32% 42%)`, opacity: 0.6 }}
strokeWidth={1.2}
/>
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{badge && <Badge kind={badge} t={t} />}
<DiscountFlag product={product} t={t} />
</div>
<h2 className="mt-2 font-display text-[28px] font-semibold leading-tight text-[#2d3b2d]">{name}</h2>
{desc && <p className="mt-1 text-[14px] leading-relaxed text-[#7d7660]">{desc}</p>}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tags.map(tag => <DietChip key={tag} tag={tag} t={t} />)}
</div>
)}
{ingredients.length > 0 && (
<div className="mt-5">
<div className="font-sans text-[11px] font-semibold uppercase tracking-[0.14em] text-[#b3aa90]">{t.ingredients}</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{ingredients.map((ing, i) => (
<span key={i} className="rounded-lg bg-white px-2.5 py-1 text-[12.5px] text-[#5f5a48] ring-1 ring-[#ece5d5]">{ing}</span>
))}
</div>
</div>
)}
{allergens.length > 0 && (
<div className="mt-4 flex items-start gap-2 rounded-xl bg-[#f7e6dc]/60 px-3 py-2.5 ring-1 ring-[#eccab3]">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[#c2602f]" strokeWidth={2} />
<div className="text-[12.5px] leading-snug text-[#9a5732]">
<span className="font-semibold">{t.contains}: </span>
{allergens.map(a => ALLERGEN_EN[a] ?? a).join(', ')}
</div>
</div>
)}
</div>
{/* Sticky add bar */}
<div className="flex items-center gap-3 border-t border-[#ece5d5] bg-[#faf7f0] px-5 py-3.5">
{qty > 0 ? (
<Stepper qty={qty} onInc={() => onInc(product)} onDec={() => onDec(product)} />
) : (
<div className="flex items-baseline gap-2">
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
)}
<button
onClick={() => { onAdd(product); onClose() }}
className="ml-auto flex h-11 flex-1 items-center justify-center gap-2 rounded-full bg-[#2d3b2d] px-5 text-[14px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-[0.98] hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{t.add} · {eur(discountedPrice(product))}
</button>
</div>
</Sheet>
)
}
// ── Search Overlay ────────────────────────────────────────────────────────────
function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart }) {
const [q, setQ] = useState('')
const inputRef = useRef(null)
useEffect(() => { if (open && inputRef.current) inputRef.current.focus() }, [open])
useEffect(() => { if (!open) setQ('') }, [open])
const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories])
const results = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return []
return allProducts.filter(p => {
const name = typeof p.name === 'object' ? Object.values(p.name).join(' ') : (p.name ?? '')
const desc = typeof p.desc === 'object' ? Object.values(p.desc).join(' ') : (p.desc ?? '')
const ings = p.ingredients
? (Array.isArray(p.ingredients) ? p.ingredients : Object.values(p.ingredients).flat()).join(' ')
: ''
return (name + ' ' + desc + ' ' + ings).toLowerCase().includes(term)
})
}, [q, allProducts])
if (!open) return null
return (
<div className="fixed inset-0 z-50 mx-auto flex w-full sm:max-w-[960px] flex-col bg-[#faf7f0] animate-fade">
<div className="flex items-center gap-2 px-3 py-3">
<div className="flex flex-1 items-center gap-2 rounded-full bg-white px-3.5 py-2.5 ring-1 ring-[#e8e1d1]">
<Search className="h-[18px] w-[18px] text-[#b3aa90]" strokeWidth={1.9} />
<input
ref={inputRef}
value={q}
onChange={e => setQ(e.target.value)}
placeholder={t.searchPlaceholder}
className="w-full bg-transparent text-[15px] text-[#2d3b2d] outline-none placeholder:text-[#b3aa90]"
/>
{q && (
<button onClick={() => setQ('')}>
<X className="h-4 w-4 text-[#b3aa90]" />
</button>
)}
</div>
<button onClick={onClose} className="px-1 text-[14px] font-medium text-[#6d6a59]">
{t.back}
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-6">
{q && results.length === 0 && (
<div className="mt-24 text-center">
<SearchX className="mx-auto h-10 w-10 text-[#cfc6ad]" strokeWidth={1.4} />
<div className="mt-3 font-display text-[20px] text-[#2d3b2d]">{t.noResults}</div>
<div className="mt-1 text-[13px] text-[#9a917a]">{t.noResultsSub}</div>
</div>
)}
{results.length > 0 && (
<div className="flex flex-col gap-3 pt-1">
{results.map(p => {
const cat = categories.find(c => c.id === p.cat)
return (
<ProductCard
key={p.id}
product={p}
category={cat}
lang={lang}
t={t}
onOpen={prod => { onClose(); onOpen(prod) }}
onAdd={onAdd}
qty={cart[p.id] || 0}
/>
)
})}
</div>
)}
{!q && (
<div className="mt-24 text-center text-[#b3aa90]">
<UtensilsCrossed className="mx-auto h-10 w-10" strokeWidth={1.3} />
<div className="mt-3 text-[13px]">{t.searchPlaceholder}</div>
</div>
)}
</div>
</div>
)
}
// ── Cart Flow (bottom sheet) ──────────────────────────────────────────────────
function CartFlow({ stage, setStage, cart, setCart, categories, lang, t, onOpenProduct, siteSlug, navigate }) {
const [form, setForm] = useState({ name: '', table: '' })
const [sending, setSending] = useState(false)
const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories])
const lines = useMemo(() =>
Object.entries(cart)
.map(([id, qty]) => ({ product: allProducts.find(p => p.id === id), qty }))
.filter(l => l.product && l.qty > 0),
[cart, allProducts]
)
const total = lines.reduce((s, l) => s + discountedPrice(l.product) * l.qty, 0)
const inc = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
const dec = p => setCart(c => {
const n = (c[p.id] || 0) - 1
const next = { ...c }
if (n <= 0) delete next[p.id]; else next[p.id] = n
return next
})
const submit = async () => {
setSending(true)
try {
const result = await submitOrder(siteSlug, {
order_type: 'dine_in',
customer_name: form.name.trim(),
customer_table: form.table.trim(),
items: lines.map(l => ({
product_id: l.product.id,
name: typeof l.product.name === 'object' ? l.product.name.en : l.product.name,
quantity: l.qty,
unit_price: discountedPrice(l.product),
})),
subtotal: Math.round(total * 100) / 100,
total: Math.round(total * 100) / 100,
lang,
placed_at: new Date().toISOString(),
})
setCart({})
setForm({ name: '', table: '' })
setStage(null)
navigate(`/${siteSlug}/confirm/${result.public_ref}`)
} catch {
setSending(false)
}
}
const close = () => setStage(null)
// Cart view
if (stage === 'cart') {
return (
<Sheet open onClose={close}>
<SheetHandle />
<div className="flex items-center justify-between px-5 pb-1 pt-3">
<h2 className="font-display text-[24px] font-semibold text-[#2d3b2d]">{t.yourOrder}</h2>
<button onClick={close} className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]">
<X className="h-4 w-4" strokeWidth={2} />
</button>
</div>
{lines.length === 0 ? (
<div className="flex flex-col items-center px-6 py-16 text-center">
<ShoppingBag className="h-12 w-12 text-[#cfc6ad]" strokeWidth={1.3} />
<div className="mt-4 font-display text-[20px] text-[#2d3b2d]">{t.emptyCart}</div>
<div className="mt-1 text-[13px] text-[#9a917a]">{t.emptyCartSub}</div>
</div>
) : (
<>
<div className="max-h-[56vh] overflow-y-auto px-4 py-3">
<div className="flex flex-col gap-2.5">
{lines.map(l => {
const cat = categories.find(c => c.id === l.product.cat)
const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name
return (
<div key={l.product.id} className="flex items-center gap-3 rounded-2xl bg-white p-2.5 ring-1 ring-[#ece5d5]">
<DishArt product={l.product} category={cat} size="sm" />
<div className="min-w-0 flex-1">
<div
onClick={() => { close(); onOpenProduct(l.product) }}
className="cursor-pointer font-display text-[16px] font-semibold leading-tight text-[#2d3b2d]"
>
{pname}
</div>
<div className="mt-0.5 font-display text-[14px] text-[#857e69]">{eur(discountedPrice(l.product))}</div>
</div>
<Stepper qty={l.qty} onInc={() => inc(l.product)} onDec={() => dec(l.product)} />
</div>
)
})}
</div>
</div>
<div className="border-t border-[#ece5d5] px-5 py-3.5">
<div className="mb-2.5 flex items-baseline justify-between">
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
</div>
<button
onClick={() => setStage('checkout')}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-[#2d3b2d] text-[15px] font-semibold text-[#f0e9d6] transition active:scale-[0.98]"
>
{t.continue}<ArrowRight className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
</>
)}
</Sheet>
)
}
// Checkout view
if (stage === 'checkout') {
const valid = form.name.trim() && form.table.trim()
return (
<Sheet open onClose={close}>
<SheetHandle />
<div className="flex items-center gap-3 px-5 pb-1 pt-3">
<button
onClick={() => setStage('cart')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]"
>
<ChevronLeft className="h-4 w-4" strokeWidth={2.2} />
</button>
<h2 className="font-display text-[22px] font-semibold text-[#2d3b2d]">{t.placeOrder}</h2>
</div>
<div className="max-h-[62vh] overflow-y-auto px-5 pb-2 pt-3">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.name}</span>
<input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder={t.namePh}
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
/>
</label>
<label className="block">
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.table}</span>
<input
value={form.table}
onChange={e => setForm(f => ({ ...f, table: e.target.value }))}
placeholder={t.tablePh}
inputMode="numeric"
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
/>
</label>
</div>
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.orderSummary}</div>
<div className="mt-2 overflow-hidden rounded-2xl bg-white ring-1 ring-[#ece5d5]">
{lines.map((l, i) => {
const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name
return (
<div key={l.product.id} className={`flex items-center justify-between px-4 py-2.5 ${i > 0 ? 'border-t border-[#f0ebdd]' : ''}`}>
<span className="text-[13.5px] text-[#5f5a48]">
<b className="font-display text-[#2d3b2d]">{l.qty}×</b> {pname}
</span>
<span className="font-display text-[14px] font-semibold tabular-nums text-[#2d3b2d]">
{eur(discountedPrice(l.product) * l.qty)}
</span>
</div>
)
})}
</div>
</div>
<div className="border-t border-[#ece5d5] px-5 py-3.5">
<div className="mb-2.5 flex items-baseline justify-between">
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
</div>
<button
disabled={!valid || sending}
onClick={submit}
className={`flex h-12 w-full items-center justify-center gap-2 rounded-full text-[15px] font-semibold transition active:scale-[0.98] ${
valid && !sending ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'bg-[#d8d2c1] text-[#a39c87] cursor-not-allowed'
}`}
>
{sending
? <><Loader2 className="h-4 w-4 animate-spin" /> {t.sending}</>
: <><Send className="h-4 w-4" strokeWidth={2} /> {t.placeOrder}</>
}
</button>
</div>
</Sheet>
)
}
return null
}
// ── Floating Cart Button ──────────────────────────────────────────────────────
function CartButton({ count, total, t, onClick }) {
const [bump, setBump] = useState(false)
const prev = useRef(count)
useEffect(() => {
if (count !== prev.current && count > 0) {
setBump(true)
const tm = setTimeout(() => setBump(false), 320)
prev.current = count
return () => clearTimeout(tm)
}
prev.current = count
}, [count])
if (count === 0) return null
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 mx-auto w-full sm:max-w-[960px] px-4 pb-4">
<button
onClick={onClick}
className={`pointer-events-auto flex h-14 w-full items-center justify-between rounded-full bg-[#2d3b2d] px-5 text-[#f0e9d6] shadow-cart transition active:scale-[0.98] ${bump ? 'animate-pop' : ''}`}
>
<span className="flex items-center gap-2.5">
<span className="relative">
<ShoppingBag className="h-[22px] w-[22px]" strokeWidth={1.8} />
<span className="absolute -right-2 -top-2 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-[#c9a24b] px-1 text-[11px] font-bold text-[#2d3b2d] tabular-nums">
{count}
</span>
</span>
<span className="text-[14px] font-semibold">{t.yourOrder}</span>
</span>
<span className="font-display text-[18px] font-semibold tabular-nums">{eur(total)}</span>
</button>
</div>
)
}
// ── Main MenuPage ─────────────────────────────────────────────────────────────
export default function MenuPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const [categories, setCategories] = useState([])
const [restaurant, setRestaurant] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const [lang, setLang] = useState(() => {
try { return localStorage.getItem('ot_lang') || 'en' } catch { return 'en' }
})
const [cart, setCart] = useState(() => {
try { return JSON.parse(localStorage.getItem('ot_cart')) || {} } catch { return {} }
})
const [active, setActive] = useState(null)
const [activeProduct, setActiveProduct] = useState(null)
const [activeProductCat, setActiveProductCat] = useState(null)
const [searchOpen, setSearchOpen] = useState(false)
const [stage, setStage] = useState(null)
const t = I18N[lang] ?? I18N.en
useEffect(() => { try { localStorage.setItem('ot_lang', lang) } catch {} }, [lang])
useEffect(() => { try { localStorage.setItem('ot_cart', JSON.stringify(cart)) } catch {} }, [cart])
useEffect(() => {
fetchMenu(siteSlug)
.then(data => {
const cats = normaliseCategories(data.categories || [])
setCategories(cats)
setRestaurant(data.restaurant ?? null)
if (cats.length) setActive(cats[0].id)
})
.catch(() => setError('Menu not available. Please try again.'))
.finally(() => setLoading(false))
}, [siteSlug])
const sectionRefs = useRef({})
const clickLock = useRef(false)
// Scroll-spy
useEffect(() => {
const onScroll = () => {
if (clickLock.current) return
let current = categories[0]?.id
for (const c of categories) {
const el = sectionRefs.current[c.id]
if (el && el.getBoundingClientRect().top <= 90) current = c.id
}
if (current) setActive(a => a === current ? a : current)
}
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => window.removeEventListener('scroll', onScroll)
}, [categories])
const pick = id => {
const el = sectionRefs.current[id]
if (!el) return
setActive(id)
clickLock.current = true
const top = el.getBoundingClientRect().top + window.scrollY - 64
window.scrollTo({ top, behavior: 'smooth' })
setTimeout(() => { clickLock.current = false }, 700)
}
const addToCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
const incCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
const decCart = p => setCart(c => {
const n = (c[p.id] || 0) - 1
const next = { ...c }
if (n <= 0) delete next[p.id]; else next[p.id] = n
return next
})
const openProduct = p => {
setActiveProduct(p)
setActiveProductCat(categories.find(c => c.id === p.cat) ?? null)
}
const count = Object.values(cart).reduce((s, q) => s + q, 0)
const total = useMemo(() => {
const allProducts = categories.flatMap(c => c.products)
return Object.entries(cart).reduce((s, [id, q]) => {
const p = allProducts.find(x => x.id === id)
return p ? s + discountedPrice(p) * q : s
}, 0)
}, [cart, categories])
if (loading) return (
<div className="min-h-dvh flex items-center justify-center bg-[#faf7f0]">
<div className="w-8 h-8 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
</div>
)
if (error) return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-3 p-8 text-center bg-[#faf7f0]">
<AlertCircle className="text-[#c2602f]" size={40} />
<p className="text-[#7d7660]">{error}</p>
</div>
)
return (
<div className="relative mx-auto min-h-dvh w-full sm:max-w-[960px] bg-[#faf7f0] sm:shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)]">
<Hero lang={lang} setLang={setLang} restaurant={restaurant} />
<CategoryBar
categories={categories}
active={active}
onPick={pick}
onSearch={() => setSearchOpen(true)}
lang={lang}
/>
<main className="pb-32">
{categories.map(cat => (
<Section
key={cat.id}
category={cat}
lang={lang}
t={t}
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
sectionRef={el => { sectionRefs.current[cat.id] = el }}
/>
))}
<footer className="mt-10 px-6 text-center">
<div className="mx-auto flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
</footer>
</main>
<CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />
<ProductSheet
product={activeProduct}
category={activeProductCat}
lang={lang}
t={t}
onClose={() => setActiveProduct(null)}
onAdd={addToCart}
qty={activeProduct ? (cart[activeProduct.id] || 0) : 0}
onInc={incCart}
onDec={decCart}
/>
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
categories={categories}
lang={lang}
t={t}
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
/>
<CartFlow
stage={stage}
setStage={setStage}
cart={cart}
setCart={setCart}
categories={categories}
lang={lang}
t={t}
onOpenProduct={openProduct}
siteSlug={siteSlug}
navigate={navigate}
/>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Check, Clock, X, ChefHat, Truck, Package, Leaf } from 'lucide-react'
import { fetchOrderStatus } from '../api'
const STATUS_CONFIG = {
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: '#c9a24b', bg: '#f6edd6' },
accepted: { label: 'Order accepted!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
rejected: { label: 'Order declined', Icon: X, color: '#c2602f', bg: '#f7e6dc' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: '#2d3b2d', bg: '#e7ede7' },
ready: { label: 'Ready for pickup!', Icon: Package, color: '#3f7d4e', bg: '#e7f1e7' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: '#2d3b2d', bg: '#e7ede7' },
delivered: { label: 'Delivered!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
}
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="relative mx-auto min-h-dvh max-w-[480px] bg-[#faf7f0] shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)] flex flex-col items-center justify-center px-6 py-12">
{/* Ornament top */}
<div className="mb-8 flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
{/* Status icon */}
<div
className="flex h-20 w-20 items-center justify-center rounded-full"
style={{ background: bg }}
>
<Icon size={38} style={{ color }} strokeWidth={2.2} />
</div>
{/* Ref number */}
<p className="mt-5 font-sans text-[11px] font-semibold uppercase tracking-[0.22em] text-[#b3aa90]">
{ref}
</p>
{/* Status label */}
<h1 className="mt-2 font-display text-[28px] font-semibold leading-tight text-center text-[#2d3b2d]">
{error ? 'Could not load status' : label}
</h1>
{/* Rejection reason */}
{rejectionReason && (
<p className="mt-2 text-[14px] text-center text-[#7d7660]">
Reason: {rejectionReason}
</p>
)}
{/* Subtext */}
{orderStatus === 'delivered' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
Thank you for your order! Enjoy your meal.
</p>
)}
{orderStatus === 'rejected' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
We're sorry we couldn't take your order this time. Please try again or speak to staff.
</p>
)}
{!TERMINAL.has(orderStatus) && !error && (
<p className="mt-3 text-[13px] text-[#9a917a]">
We'll update this page automatically. Keep it open.
</p>
)}
{/* Spinner */}
{!TERMINAL.has(orderStatus) && !error && (
<div className="mt-6 flex justify-center">
<div className="h-5 w-5 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
</div>
)}
{/* Ornament bottom */}
<div className="mt-10 flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
display: ['Bricolage Grotesque', 'Noto Sans', 'system-ui', 'sans-serif'],
sans: ['Hanken Grotesk', 'Noto Sans', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
dark: '#2d3b2d',
hover: '#26331f',
},
cream: '#faf7f0',
card: '#fcfbf7',
gold: '#c9a24b',
terracotta: '#c2602f',
sage: '#9caf88',
success: '#3f7d4e',
},
borderRadius: {
card: '20px',
section: '22px',
sheet: '24px',
},
boxShadow: {
card: '0 5px 16px -10px rgba(45,42,31,0.45)',
'card-hover': '0 10px 24px -12px rgba(45,42,31,0.5)',
cart: '0 12px 28px -8px rgba(45,59,45,0.55)',
},
keyframes: {
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
slideup: {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
pop: {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.06)' },
'100%': { transform: 'scale(1)' },
},
},
animation: {
fade: 'fade 0.2s ease',
slideup: 'slideup 0.28s cubic-bezier(0.22,1,0.36,1)',
pop: 'pop 0.32s ease',
},
},
},
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;
}
}

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
@@ -2472,6 +2473,15 @@
"node": ">=10"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",

View File

@@ -22,3 +22,16 @@ client.interceptors.response.use(
)
export default client
// ── Remote Manager accounts ───────────────────────────────────────────────────
export const getManagersBySite = (siteId) =>
client.get(`/api/manager/by-site/${siteId}`)
export const addManagerToSite = (email, fullName, password, siteId) =>
client.post('/api/manager/register', {
email, full_name: fullName, password, site_ids: [siteId],
})
export const removeManagerSiteAccess = (managerId, siteId) =>
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import { QRCodeCanvas } from 'qrcode.react'
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
import LicenseStatus from '../components/LicenseStatus'
import ConfirmModal from '../components/ConfirmModal'
@@ -26,12 +27,21 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
const [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false)
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
const [newMgrEmail, setNewMgrEmail] = useState('')
const [newMgrName, setNewMgrName] = useState('')
const [newMgrPass, setNewMgrPass] = useState('')
const [addingMgr, setAddingMgr] = useState(false)
const [removingMgrId, setRemovingMgrId] = useState(null)
useEffect(() => {
async function load() {
try {
@@ -47,6 +57,49 @@ export default function SiteDetailPage() {
load()
}, [siteId])
const fetchManagers = useCallback(async () => {
setManagersLoading(true)
try {
const { data } = await getManagersBySite(siteId)
setManagers(data)
} catch {
toast.error('Failed to load managers')
} finally {
setManagersLoading(false)
}
}, [siteId])
useEffect(() => { fetchManagers() }, [fetchManagers])
async function doAddManager() {
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
setAddingMgr(true)
try {
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), siteId)
toast.success('Manager added')
setModal(null)
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
fetchManagers()
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to add manager')
} finally {
setAddingMgr(false)
}
}
async function doRemoveManager(managerId) {
setRemovingMgrId(managerId)
try {
await removeManagerSiteAccess(managerId, siteId)
toast.success('Access removed')
setManagers(prev => prev.filter(m => m.id !== managerId))
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to remove access')
} finally {
setRemovingMgrId(null)
}
}
async function doLock() {
if (!lockReason.trim()) return
setActing(true)
@@ -270,6 +323,99 @@ export default function SiteDetailPage() {
</div>
</div>
{/* QR Codes */}
{(() => {
const slug = site.site_id
const menuUrl = `http://72.61.191.197:3100/menu/${slug}`
const orderUrl = `http://72.61.191.197:3100/menu/${slug}/order`
function handleDownload(canvasId, filename) {
const canvas = document.getElementById(canvasId)
if (!canvas) return
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
}
return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">QR Codes</h2>
<div className="flex flex-wrap gap-8">
{[
{ id: `qr-menu-${site.id}`, url: menuUrl, label: 'Menu QR Code', file: `menu-qr-${slug}.png` },
{ id: `qr-order-${site.id}`, url: orderUrl, label: 'Order QR Code', file: `order-qr-${slug}.png` },
].map(({ id, url, label, file }) => (
<div key={id} className="flex flex-col items-center gap-3">
<p className="text-xs font-medium text-gray-400">{label}</p>
<div className="bg-white p-3 rounded-xl">
<QRCodeCanvas id={id} value={url} size={180} includeMargin={false} />
</div>
<p className="text-xs text-gray-600 font-mono break-all max-w-[220px] text-center">{url}</p>
<button
onClick={() => handleDownload(id, file)}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
Download PNG
</button>
</div>
))}
</div>
</div>
)
})()}
{/* Remote Managers */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
<button
onClick={() => setModal('add_manager')}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
+ Add Manager
</button>
</div>
{managersLoading && (
<p className="text-xs text-gray-500">Loading</p>
)}
{!managersLoading && managers.length === 0 && (
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
)}
{!managersLoading && managers.length > 0 && (
<div className="space-y-2">
{managers.map(mgr => (
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
{mgr.full_name && (
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
}`}>
{mgr.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => doRemoveManager(mgr.id)}
disabled={removingMgrId === mgr.id}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{removingMgrId === mgr.id ? '…' : 'Remove'}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
{modal === 'lock' && (
<ConfirmModal
@@ -349,6 +495,48 @@ export default function SiteDetailPage() {
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
onConfirm={doAddManager}
>
<div className="space-y-3 mb-2">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
<input
type="email"
value={newMgrEmail}
onChange={e => setNewMgrEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
<input
type="text"
value={newMgrName}
onChange={e => setNewMgrName(e.target.value)}
placeholder="Jane Smith"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
<input
type="password"
value={newMgrPass}
onChange={e => setNewMgrPass(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
</ConfirmModal>
)}
</div>
)
}