Compare commits
9 Commits
2d1b927670
...
b45a93a559
| Author | SHA1 | Date | |
|---|---|---|---|
| b45a93a559 | |||
| 7d75ef44f7 | |||
| 3598a929e0 | |||
| f291234c14 | |||
| 6c5e35d537 | |||
| 12bd589bb7 | |||
| abe7f4ff0d | |||
| cd8a6c53cf | |||
| 835806f92d |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
27
cloud_backend/models/manager_account.py
Normal file
27
cloud_backend/models/manager_account.py
Normal 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")
|
||||
12
cloud_backend/models/menu_snapshot.py
Normal file
12
cloud_backend/models/menu_snapshot.py
Normal 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())
|
||||
41
cloud_backend/models/online_order.py
Normal file
41
cloud_backend/models/online_order.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
12
cloud_backend/models/stats_snapshot.py
Normal file
12
cloud_backend/models/stats_snapshot.py
Normal 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())
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
91
cloud_backend/routers/manager_auth.py
Normal file
91
cloud_backend/routers/manager_auth.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.manager_account import ManagerAccount
|
||||
from models.site import Site
|
||||
from auth_utils import get_current_admin
|
||||
from schemas.manager import (
|
||||
ManagerRegisterRequest, ManagerLoginRequest,
|
||||
ManagerTokenOut, ManagerOut,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
# ── Shared dependency: decode manager JWT ─────────────────────────────────────
|
||||
|
||||
def _get_current_manager(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> ManagerAccount:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM])
|
||||
manager_id = int(payload.get("sub", 0))
|
||||
except (JWTError, ValueError):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first()
|
||||
if not manager or not manager.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive")
|
||||
return manager
|
||||
|
||||
|
||||
def _create_manager_token(manager: ManagerAccount) -> str:
|
||||
site_ids = [s.id for s in manager.sites]
|
||||
payload = {
|
||||
"sub": str(manager.id),
|
||||
"email": manager.email,
|
||||
"site_ids": site_ids,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
# ── Admin-only: register a manager ───────────────────────────────────────────
|
||||
|
||||
@router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED)
|
||||
def register_manager(
|
||||
body: ManagerRegisterRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_admin=Depends(get_current_admin),
|
||||
):
|
||||
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
|
||||
manager = ManagerAccount(
|
||||
email=body.email,
|
||||
password_hash=_pwd.hash(body.password),
|
||||
full_name=body.full_name,
|
||||
sites=sites,
|
||||
)
|
||||
db.add(manager)
|
||||
db.commit()
|
||||
db.refresh(manager)
|
||||
return manager
|
||||
|
||||
|
||||
# ── Public: manager login ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/login", response_model=ManagerTokenOut)
|
||||
def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
|
||||
manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
|
||||
if not manager or not _pwd.verify(body.password, manager.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if not manager.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
|
||||
return ManagerTokenOut(access_token=_create_manager_token(manager))
|
||||
|
||||
|
||||
# ── Public: refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/refresh", response_model=ManagerTokenOut)
|
||||
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
|
||||
return ManagerTokenOut(access_token=_create_manager_token(manager))
|
||||
54
cloud_backend/routers/menu.py
Normal file
54
cloud_backend/routers/menu.py
Normal 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)
|
||||
141
cloud_backend/routers/orders.py
Normal file
141
cloud_backend/routers/orders.py
Normal 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()
|
||||
171
cloud_backend/routers/remote_dashboard.py
Normal file
171
cloud_backend/routers/remote_dashboard.py
Normal 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
|
||||
]
|
||||
53
cloud_backend/schemas/manager.py
Normal file
53
cloud_backend/schemas/manager.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ManagerRegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
site_ids: list[int] = []
|
||||
|
||||
|
||||
class ManagerLoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class ManagerTokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ManagerSiteOut(BaseModel):
|
||||
id: int
|
||||
site_id: str
|
||||
name: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ManagerOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: int
|
||||
created_at: datetime
|
||||
sites: list[ManagerSiteOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class StatsSnapshotOut(BaseModel):
|
||||
site_id: int
|
||||
snapshot_json: str
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class StatsSnapshotRequest(BaseModel):
|
||||
site_id: int
|
||||
snapshot_json: str
|
||||
10
cloud_backend/schemas/menu.py
Normal file
10
cloud_backend/schemas/menu.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MenuSyncRequest(BaseModel):
|
||||
site_id: int
|
||||
snapshot_json: str
|
||||
|
||||
|
||||
class MenuSyncResponse(BaseModel):
|
||||
ok: bool
|
||||
63
cloud_backend/schemas/online_order.py
Normal file
63
cloud_backend/schemas/online_order.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
25
connect_frontend/Dockerfile
Normal file
25
connect_frontend/Dockerfile
Normal 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
|
||||
11
connect_frontend/manager-app/Dockerfile
Normal file
11
connect_frontend/manager-app/Dockerfile
Normal 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
|
||||
15
connect_frontend/manager-app/index.html
Normal file
15
connect_frontend/manager-app/index.html
Normal 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>
|
||||
26
connect_frontend/manager-app/package.json
Normal file
26
connect_frontend/manager-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
connect_frontend/manager-app/postcss.config.js
Normal file
1
connect_frontend/manager-app/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
24
connect_frontend/manager-app/src/App.jsx
Normal file
24
connect_frontend/manager-app/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
connect_frontend/manager-app/src/api.js
Normal file
58
connect_frontend/manager-app/src/api.js
Normal 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,
|
||||
})
|
||||
}
|
||||
41
connect_frontend/manager-app/src/components/OrderCard.jsx
Normal file
41
connect_frontend/manager-app/src/components/OrderCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
9
connect_frontend/manager-app/src/components/StatCard.jsx
Normal file
9
connect_frontend/manager-app/src/components/StatCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
connect_frontend/manager-app/src/components/StatusBadge.jsx
Normal file
27
connect_frontend/manager-app/src/components/StatusBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
connect_frontend/manager-app/src/index.css
Normal file
6
connect_frontend/manager-app/src/index.css
Normal 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; }
|
||||
15
connect_frontend/manager-app/src/main.jsx
Normal file
15
connect_frontend/manager-app/src/main.jsx
Normal 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>
|
||||
)
|
||||
106
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
106
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, RefreshCw, Bell } from 'lucide-react'
|
||||
import { fetchSnapshot, fetchPendingOrders } from '../api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import OrderCard from '../components/OrderCard'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [snapshot, setSnapshot] = useState(null)
|
||||
const [pending, setPending] = useState([])
|
||||
const [lastUpdated, setLastUpdated] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [snap, orders] = await Promise.all([
|
||||
fetchSnapshot(siteId).catch(() => null),
|
||||
fetchPendingOrders(siteId).catch(() => []),
|
||||
])
|
||||
setSnapshot(snap ? JSON.parse(snap.snapshot_json) : null)
|
||||
setPending(orders)
|
||||
setLastUpdated(new Date())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const timer = setInterval(load, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [siteId])
|
||||
|
||||
const stats = snapshot || {}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={() => navigate('/')} className="text-slate-400 hover:text-slate-700">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-slate-800">Dashboard</h1>
|
||||
<button onClick={load} className="text-slate-400 hover:text-slate-700">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-5 space-y-5">
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label="Open tables" value={stats.open_tables} />
|
||||
<StatCard label="Revenue today" value={stats.today_revenue != null ? `€${stats.today_revenue.toFixed(2)}` : null} color="text-emerald-600" />
|
||||
<StatCard label="Orders today" value={stats.today_orders} />
|
||||
<StatCard label="Online pending" value={stats.online_orders_pending} color={stats.online_orders_pending > 0 ? 'text-amber-600' : 'text-slate-800'} />
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-slate-400 text-right">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Pending orders */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Bell size={16} className={pending.length ? 'text-amber-500' : 'text-slate-300'} />
|
||||
Pending orders
|
||||
{pending.length > 0 && (
|
||||
<span className="bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate(`/${siteId}/orders`)}
|
||||
className="text-sky-500 text-sm font-medium hover:underline"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-8 text-center text-slate-400 text-sm shadow-sm">
|
||||
No pending orders
|
||||
</div>
|
||||
) : (
|
||||
pending.map(o => <OrderCard key={o.id} order={o} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
77
connect_frontend/manager-app/src/pages/LoginPage.jsx
Normal file
77
connect_frontend/manager-app/src/pages/LoginPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
connect_frontend/manager-app/src/pages/OrderDetailPage.jsx
Normal file
175
connect_frontend/manager-app/src/pages/OrderDetailPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx
Normal file
74
connect_frontend/manager-app/src/pages/OrderHistoryPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx
Normal file
66
connect_frontend/manager-app/src/pages/SiteSelectorPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
connect_frontend/manager-app/tailwind.config.js
Normal file
10
connect_frontend/manager-app/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
9
connect_frontend/manager-app/vite.config.js
Normal file
9
connect_frontend/manager-app/vite.config.js
Normal 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' },
|
||||
})
|
||||
11
connect_frontend/menu-app/Dockerfile
Normal file
11
connect_frontend/menu-app/Dockerfile
Normal 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
|
||||
15
connect_frontend/menu-app/index.html
Normal file
15
connect_frontend/menu-app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Xenia Menu</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
connect_frontend/menu-app/package.json
Normal file
26
connect_frontend/menu-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
connect_frontend/menu-app/postcss.config.js
Normal file
1
connect_frontend/menu-app/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
15
connect_frontend/menu-app/src/App.jsx
Normal file
15
connect_frontend/menu-app/src/App.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import MenuPage from './pages/MenuPage'
|
||||
import CartPage from './pages/CartPage'
|
||||
import OrderConfirm from './pages/OrderConfirm'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/:siteSlug" element={<MenuPage />} />
|
||||
<Route path="/:siteSlug/order" element={<CartPage />} />
|
||||
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
20
connect_frontend/menu-app/src/api.js
Normal file
20
connect_frontend/menu-app/src/api.js
Normal 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 }
|
||||
}
|
||||
25
connect_frontend/menu-app/src/components/CartButton.jsx
Normal file
25
connect_frontend/menu-app/src/components/CartButton.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
|
||||
export default function CartButton({ cart, siteSlug }) {
|
||||
const navigate = useNavigate()
|
||||
const count = cart.reduce((s, i) => s + i.quantity, 0)
|
||||
const total = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
|
||||
if (!count) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-0 right-0 flex justify-center px-4 z-30">
|
||||
<button
|
||||
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl px-6 py-3.5 shadow-lg flex items-center gap-3 w-full max-w-sm transition-colors"
|
||||
>
|
||||
<span className="bg-emerald-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
|
||||
{count}
|
||||
</span>
|
||||
<span className="flex-1 font-semibold text-left">View order</span>
|
||||
<span className="font-bold">€{total.toFixed(2)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
connect_frontend/menu-app/src/components/CategoryNav.jsx
Normal file
19
connect_frontend/menu-app/src/components/CategoryNav.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export default function CategoryNav({ categories, active, onSelect }) {
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1 scrollbar-hide">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => onSelect(cat.id)}
|
||||
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
active === cat.id
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
connect_frontend/menu-app/src/components/ProductCard.jsx
Normal file
55
connect_frontend/menu-app/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
export default function ProductCard({ product, onSelect }) {
|
||||
const unavailable = !product.digital_available
|
||||
const basePrice = product.digital_price ?? product.base_price
|
||||
const hasDiscount = product.digital_discount > 0 && !product.digital_price
|
||||
const displayPrice = hasDiscount
|
||||
? basePrice * (1 - product.digital_discount / 100)
|
||||
: basePrice
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
className={`w-full bg-white rounded-2xl shadow-sm p-4 flex gap-4 text-left transition-shadow ${
|
||||
unavailable ? 'opacity-60 cursor-default' : 'hover:shadow-md active:scale-[0.99]'
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
{(product.digital_image_url || product.image_url) && (
|
||||
<img
|
||||
src={product.digital_image_url || product.image_url}
|
||||
alt={product.digital_name || product.name}
|
||||
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-slate-800 leading-tight">
|
||||
{product.digital_name || product.name}
|
||||
</h3>
|
||||
{unavailable && (
|
||||
<span className="flex-shrink-0 text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full">
|
||||
Out of stock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.digital_description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-2">{product.digital_description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-baseline gap-2 pt-1">
|
||||
<span className="font-bold text-emerald-600">€{displayPrice.toFixed(2)}</span>
|
||||
{hasDiscount && (
|
||||
<>
|
||||
<span className="text-xs text-slate-400 line-through">€{basePrice.toFixed(2)}</span>
|
||||
<span className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded-full font-semibold">
|
||||
-{product.digital_discount}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
6
connect_frontend/menu-app/src/index.css
Normal file
6
connect_frontend/menu-app/src/index.css
Normal 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; }
|
||||
15
connect_frontend/menu-app/src/main.jsx
Normal file
15
connect_frontend/menu-app/src/main.jsx
Normal 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>
|
||||
)
|
||||
178
connect_frontend/menu-app/src/pages/CartPage.jsx
Normal file
178
connect_frontend/menu-app/src/pages/CartPage.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
|
||||
import { submitOrder } from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function CartPage() {
|
||||
const { siteSlug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { state } = useLocation()
|
||||
const cart = state?.cart || []
|
||||
|
||||
const [orderType, setOrderType] = useState('dine_in')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const subtotal = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const deliveryFee = orderType === 'delivery' ? 2.0 : 0.0
|
||||
const total = subtotal + deliveryFee
|
||||
|
||||
if (!cart.length) {
|
||||
navigate(`/${siteSlug}`, { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) { toast.error('Please enter your name'); return }
|
||||
if (orderType === 'delivery' && !address.trim()) {
|
||||
toast.error('Please enter your delivery address')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await submitOrder(siteSlug, {
|
||||
order_type: orderType,
|
||||
customer_name: name.trim(),
|
||||
customer_phone: phone.trim() || null,
|
||||
customer_address: orderType === 'delivery' ? address.trim() : null,
|
||||
customer_notes: notes.trim() || null,
|
||||
items: cart.map(i => ({
|
||||
product_id: i.product_id,
|
||||
name: i.name,
|
||||
quantity: i.quantity,
|
||||
unit_price: i.unit_price,
|
||||
options: i.options || [],
|
||||
})),
|
||||
subtotal,
|
||||
delivery_fee: deliveryFee,
|
||||
total,
|
||||
})
|
||||
navigate(`/${siteSlug}/confirm/${result.public_ref}`, { replace: true })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Failed to place order. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 shadow-sm">
|
||||
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-500 hover:text-slate-800">
|
||||
<ArrowLeft size={22} />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-slate-800">Your Order</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-lg mx-auto px-4 py-5 space-y-5">
|
||||
|
||||
{/* Order type */}
|
||||
<div className="bg-white rounded-2xl p-4 space-y-3 shadow-sm">
|
||||
<p className="text-sm font-semibold text-slate-700">Order type</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed },
|
||||
{ id: 'delivery', label: 'Delivery', Icon: Truck },
|
||||
].map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setOrderType(id)}
|
||||
className={`flex flex-col items-center gap-2 py-4 rounded-xl border-2 transition-colors ${
|
||||
orderType === id
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart summary */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
|
||||
{cart.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-sm">
|
||||
<span className="text-slate-700">{item.quantity}× {item.name}</span>
|
||||
<span className="text-slate-600 font-medium">€{(item.unit_price * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Subtotal</span><span>€{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{deliveryFee > 0 && (
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Delivery fee</span><span>€{deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-slate-800">
|
||||
<span>Total</span><span>€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer details */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">Your details</p>
|
||||
{[
|
||||
{ label: 'Name *', value: name, set: setName, type: 'text', placeholder: 'Full name' },
|
||||
{ label: 'Phone', value: phone, set: setPhone, type: 'tel', placeholder: 'Optional' },
|
||||
].map(({ label, value, set, type, placeholder }) => (
|
||||
<div key={label}>
|
||||
<label className="text-xs text-slate-500 font-medium">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => set(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{orderType === 'delivery' && (
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 font-medium">Delivery address *</label>
|
||||
<textarea
|
||||
value={address}
|
||||
onChange={e => setAddress(e.target.value)}
|
||||
placeholder="Street, number, city"
|
||||
rows={2}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 font-medium">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Allergies, special requests…"
|
||||
rows={2}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-4 rounded-xl font-bold text-base transition-colors"
|
||||
>
|
||||
{submitting ? 'Placing order…' : `Place order · €${total.toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
connect_frontend/menu-app/src/pages/MenuPage.jsx
Normal file
119
connect_frontend/menu-app/src/pages/MenuPage.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ShoppingCart, AlertCircle } from 'lucide-react'
|
||||
import { fetchMenu } from '../api'
|
||||
import CategoryNav from '../components/CategoryNav'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import CartButton from '../components/CartButton'
|
||||
import ProductModal from './ProductModal'
|
||||
|
||||
export default function MenuPage() {
|
||||
const { siteSlug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [menu, setMenu] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCat, setActiveCat] = useState(null)
|
||||
const [cart, setCart] = useState([])
|
||||
const [selected, setSelected] = useState(null) // product for modal
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenu(siteSlug)
|
||||
.then(data => {
|
||||
setMenu(data)
|
||||
if (data.categories?.length) setActiveCat(data.categories[0].id)
|
||||
})
|
||||
.catch(() => setError('Menu not available. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteSlug])
|
||||
|
||||
function addToCart(product, quantity, options) {
|
||||
const price = product.digital_price ?? product.base_price
|
||||
const discounted = product.digital_discount > 0 && !product.digital_price
|
||||
? price * (1 - product.digital_discount / 100)
|
||||
: price
|
||||
setCart(prev => {
|
||||
const key = product.id
|
||||
const existing = prev.find(i => i.product_id === key)
|
||||
if (existing) {
|
||||
return prev.map(i => i.product_id === key
|
||||
? { ...i, quantity: i.quantity + quantity }
|
||||
: i)
|
||||
}
|
||||
return [...prev, {
|
||||
product_id: product.id,
|
||||
name: product.digital_name || product.name,
|
||||
quantity,
|
||||
unit_price: discounted,
|
||||
options,
|
||||
}]
|
||||
})
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
const activeCategory = menu?.categories?.find(c => c.id === activeCat)
|
||||
const cartCount = cart.reduce((s, i) => s + i.quantity, 0)
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<AlertCircle className="text-red-400" size={40} />
|
||||
<p className="text-slate-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-28">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-white border-b border-slate-100 shadow-sm">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-slate-800">Menu</h1>
|
||||
{cartCount > 0 && (
|
||||
<button
|
||||
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
|
||||
className="flex items-center gap-2 bg-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold"
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
{cartCount} items
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryNav
|
||||
categories={menu.categories}
|
||||
active={activeCat}
|
||||
onSelect={setActiveCat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div className="max-w-2xl mx-auto px-4 pt-4 space-y-3">
|
||||
{activeCategory?.products.map(p => (
|
||||
<ProductCard
|
||||
key={p.id}
|
||||
product={p}
|
||||
onSelect={() => setSelected(p)}
|
||||
/>
|
||||
))}
|
||||
{!activeCategory?.products.length && (
|
||||
<p className="text-center text-slate-400 py-12">No items in this category.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CartButton cart={cart} siteSlug={siteSlug} />
|
||||
|
||||
{selected && (
|
||||
<ProductModal
|
||||
product={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onAdd={addToCart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
connect_frontend/menu-app/src/pages/OrderConfirm.jsx
Normal file
90
connect_frontend/menu-app/src/pages/OrderConfirm.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } from 'lucide-react'
|
||||
import { fetchOrderStatus } from '../api'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' },
|
||||
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
|
||||
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
|
||||
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
|
||||
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
}
|
||||
|
||||
const TERMINAL = new Set(['rejected', 'delivered'])
|
||||
const POLL_MS = 10_000
|
||||
|
||||
export default function OrderConfirm() {
|
||||
const { ref } = useParams()
|
||||
const [orderStatus, setOrderStatus] = useState(null)
|
||||
const [rejectionReason, setRejectionReason] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let timer
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await fetchOrderStatus(ref)
|
||||
setOrderStatus(data.status)
|
||||
setRejectionReason(data.rejection_reason)
|
||||
if (!TERMINAL.has(data.status)) {
|
||||
timer = setTimeout(poll, POLL_MS)
|
||||
}
|
||||
} catch {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
return () => clearTimeout(timer)
|
||||
}, [ref])
|
||||
|
||||
const cfg = STATUS_CONFIG[orderStatus] || STATUS_CONFIG['pending_acceptance']
|
||||
const { label, Icon, color, bg } = cfg
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
|
||||
|
||||
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
|
||||
<Icon className={color} size={40} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{error ? 'Could not load status' : label}
|
||||
</h1>
|
||||
{rejectionReason && (
|
||||
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!TERMINAL.has(orderStatus) && !error && (
|
||||
<p className="text-xs text-slate-400">
|
||||
We'll update this page automatically. Keep it open.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orderStatus === 'rejected' && (
|
||||
<p className="text-sm text-slate-500">
|
||||
We're sorry we couldn't take your order this time. Please try again or visit us in person.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orderStatus === 'delivered' && (
|
||||
<p className="text-sm text-slate-500">Thank you for your order! Enjoy your meal.</p>
|
||||
)}
|
||||
|
||||
{!TERMINAL.has(orderStatus) && !error && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
connect_frontend/menu-app/src/pages/ProductModal.jsx
Normal file
136
connect_frontend/menu-app/src/pages/ProductModal.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Plus, Minus } from 'lucide-react'
|
||||
|
||||
export default function ProductModal({ product, onClose, onAdd }) {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [selectedOptions, setSelectedOptions] = useState([])
|
||||
|
||||
const basePrice = product.digital_price ?? product.base_price
|
||||
const hasDiscount = product.digital_discount > 0 && !product.digital_price
|
||||
const displayPrice = hasDiscount
|
||||
? basePrice * (1 - product.digital_discount / 100)
|
||||
: basePrice
|
||||
|
||||
function toggleOption(opt) {
|
||||
setSelectedOptions(prev =>
|
||||
prev.find(o => o.id === opt.id)
|
||||
? prev.filter(o => o.id !== opt.id)
|
||||
: [...prev, opt]
|
||||
)
|
||||
}
|
||||
|
||||
const optionsTotal = selectedOptions.reduce((s, o) => s + (o.price || 0), 0)
|
||||
const lineTotal = (displayPrice + optionsTotal) * quantity
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{/* Image */}
|
||||
{(product.digital_image_url || product.image_url) && (
|
||||
<img
|
||||
src={product.digital_image_url || product.image_url}
|
||||
alt={product.digital_name || product.name}
|
||||
className="w-full h-48 object-cover rounded-t-2xl sm:rounded-t-2xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{product.digital_name || product.name}
|
||||
</h2>
|
||||
{product.digital_description && (
|
||||
<p className="text-sm text-slate-500 mt-1">{product.digital_description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-emerald-600">
|
||||
€{displayPrice.toFixed(2)}
|
||||
</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-sm text-slate-400 line-through">
|
||||
€{basePrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{hasDiscount && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full font-semibold">
|
||||
-{product.digital_discount}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick options */}
|
||||
{product.quick_options?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-700">Options</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.quick_options.map(opt => {
|
||||
const active = selectedOptions.find(o => o.id === opt.id)
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => toggleOption(opt)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
||||
active
|
||||
? 'bg-emerald-500 text-white border-emerald-500'
|
||||
: 'bg-white text-slate-700 border-slate-200 hover:border-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{opt.name}{opt.price > 0 ? ` +€${opt.price.toFixed(2)}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-semibold text-slate-700">Quantity</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setQuantity(q => Math.max(1, q - 1))}
|
||||
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<span className="text-lg font-bold w-6 text-center">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(q => q + 1)}
|
||||
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to cart */}
|
||||
{product.digital_available ? (
|
||||
<button
|
||||
onClick={() => onAdd(product, quantity, selectedOptions)}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3.5 rounded-xl font-semibold flex items-center justify-between px-5 transition-colors"
|
||||
>
|
||||
<span>Add to order</span>
|
||||
<span>€{lineTotal.toFixed(2)}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full bg-slate-100 text-slate-400 py-3.5 rounded-xl font-semibold text-center">
|
||||
Currently unavailable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
connect_frontend/menu-app/tailwind.config.js
Normal file
10
connect_frontend/menu-app/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
9
connect_frontend/menu-app/vite.config.js
Normal file
9
connect_frontend/menu-app/vite.config.js
Normal 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' },
|
||||
})
|
||||
21
connect_frontend/nginx-connect.conf
Normal file
21
connect_frontend/nginx-connect.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
21
nginx-connect.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user