Compare commits
16 Commits
2d1b927670
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cad6a76d3 | |||
| a6f759bf49 | |||
| ffaeab136d | |||
| 17fb3a2589 | |||
| 70c7b9564b | |||
| 82b853369b | |||
| ac58d150ea | |||
| 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
|
# The current latest release version — clients compare against this to detect updates
|
||||||
LATEST_VERSION=0.0.0
|
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 jose import jwt, JWTError
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
@@ -10,7 +10,7 @@ from database import get_db
|
|||||||
from models.admin import Admin
|
from models.admin import Admin
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ def create_access_token(data: dict) -> str:
|
|||||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
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:
|
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")
|
username: str = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
_HERE = Path(__file__).parent
|
_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):
|
class Settings(BaseSettings):
|
||||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
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
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
ADMIN_USERNAME: str = "sysadmin"
|
ADMIN_USERNAME: str = "sysadmin"
|
||||||
ADMIN_PASSWORD: str = "changeme"
|
ADMIN_PASSWORD: str = "changeme"
|
||||||
LATEST_VERSION: str = "0.0.0"
|
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")}
|
model_config = {"env_file": str(_HERE / ".env")}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ from database import engine, Base
|
|||||||
from auth_utils import hash_password
|
from auth_utils import hash_password
|
||||||
import models.admin # noqa: F401
|
import models.admin # noqa: F401
|
||||||
import models.site # 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 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():
|
def _seed_default_admin():
|
||||||
@@ -25,9 +33,26 @@ def _seed_default_admin():
|
|||||||
db.commit()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_run_migrations()
|
||||||
_seed_default_admin()
|
_seed_default_admin()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -44,6 +69,10 @@ app.add_middleware(
|
|||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
|
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
|
||||||
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
|
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")
|
@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_ip = Column(String, nullable=True)
|
||||||
last_seen_local_ip = Column(String, nullable=True)
|
last_seen_local_ip = Column(String, nullable=True)
|
||||||
waiter_domain = 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,
|
expires_at=site.license_expires_at,
|
||||||
latest_version=settings.LATEST_VERSION,
|
latest_version=settings.LATEST_VERSION,
|
||||||
waiter_domain=site.waiter_domain,
|
waiter_domain=site.waiter_domain,
|
||||||
|
site_numeric_id=site.id,
|
||||||
)
|
)
|
||||||
|
|||||||
150
cloud_backend/routers/manager_auth.py
Normal file
150
cloud_backend/routers/manager_auth.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.manager_account import ManagerAccount, manager_site_access
|
||||||
|
from models.site import Site
|
||||||
|
from auth_utils import get_current_admin
|
||||||
|
from schemas.manager import (
|
||||||
|
ManagerRegisterRequest, ManagerLoginRequest,
|
||||||
|
ManagerTokenOut, ManagerOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shared dependency: decode manager JWT ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_current_manager(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> ManagerAccount:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(credentials.credentials, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM])
|
||||||
|
manager_id = int(payload.get("sub", 0))
|
||||||
|
except (JWTError, ValueError):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first()
|
||||||
|
if not manager or not manager.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive")
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _create_manager_token(manager: ManagerAccount) -> str:
|
||||||
|
site_ids = [s.id for s in manager.sites]
|
||||||
|
payload = {
|
||||||
|
"sub": str(manager.id),
|
||||||
|
"email": manager.email,
|
||||||
|
"site_ids": site_ids,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin-only: register a manager ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def register_manager(
|
||||||
|
body: ManagerRegisterRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
|
||||||
|
sites = db.query(Site).filter(Site.site_id.in_(body.site_ids)).all()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Email already exists — just add the new site access links, don't recreate the account
|
||||||
|
for site in sites:
|
||||||
|
if site not in existing.sites:
|
||||||
|
existing.sites.append(site)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
manager = ManagerAccount(
|
||||||
|
email=body.email,
|
||||||
|
password_hash=_pwd.hash(body.password),
|
||||||
|
full_name=body.full_name,
|
||||||
|
sites=sites,
|
||||||
|
)
|
||||||
|
db.add(manager)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(manager)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public: manager login ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/login", response_model=ManagerTokenOut)
|
||||||
|
def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
|
||||||
|
manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
|
||||||
|
if not manager or not _pwd.verify(body.password, manager.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
if not manager.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
|
||||||
|
return ManagerTokenOut(access_token=_create_manager_token(manager))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public: refresh ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=ManagerTokenOut)
|
||||||
|
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
|
||||||
|
return ManagerTokenOut(access_token=_create_manager_token(manager))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin-only: list managers for a site ─────────────────────────────────────
|
||||||
|
|
||||||
|
class ManagerBySiteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_active: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
|
||||||
|
def get_managers_by_site(
|
||||||
|
site_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
return site.manager_accounts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin-only: remove a manager's access to a site ──────────────────────────
|
||||||
|
|
||||||
|
class SiteAccessRemoveRequest(BaseModel):
|
||||||
|
manager_id: int
|
||||||
|
site_id: str # site_id UUID string, not integer PK
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_manager_site_access(
|
||||||
|
body: SiteAccessRemoveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_admin=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
|
||||||
|
if not manager:
|
||||||
|
raise HTTPException(status_code=404, detail="Manager not found")
|
||||||
|
|
||||||
|
site = db.query(Site).filter(Site.site_id == body.site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
if site in manager.sites:
|
||||||
|
manager.sites.remove(site)
|
||||||
|
db.commit()
|
||||||
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[str] = [] # site_id UUID strings, not integer PKs
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerLoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerTokenOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerSiteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
site_id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_active: int
|
||||||
|
created_at: datetime
|
||||||
|
sites: list[ManagerSiteOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StatsSnapshotOut(BaseModel):
|
||||||
|
site_id: int
|
||||||
|
snapshot_json: str
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StatsSnapshotRequest(BaseModel):
|
||||||
|
site_id: int
|
||||||
|
snapshot_json: str
|
||||||
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
|
expires_at: datetime
|
||||||
latest_version: str | None = None
|
latest_version: str | None = None
|
||||||
waiter_domain: 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>
|
||||||
|
)
|
||||||
116
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
116
connect_frontend/manager-app/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { ArrowLeft, RefreshCw, Bell } from 'lucide-react'
|
||||||
|
import { fetchSnapshot, fetchPendingOrders } from '../api'
|
||||||
|
import StatCard from '../components/StatCard'
|
||||||
|
import OrderCard from '../components/OrderCard'
|
||||||
|
|
||||||
|
const POLL_MS = 15_000
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { siteId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [snapshot, setSnapshot] = useState(null)
|
||||||
|
const [pending, setPending] = useState([])
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [snap, orders] = await Promise.all([
|
||||||
|
fetchSnapshot(siteId).catch(() => null),
|
||||||
|
fetchPendingOrders(siteId).catch(() => []),
|
||||||
|
])
|
||||||
|
setSnapshot(snap ? JSON.parse(snap.snapshot_json) : null)
|
||||||
|
setPending(orders)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const timer = setInterval(load, POLL_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
const stats = snapshot || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 pb-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-slate-100 shadow-sm sticky top-0 z-10">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<button onClick={() => navigate('/')} className="text-slate-400 hover:text-slate-700">
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="font-bold text-slate-800">Dashboard</h1>
|
||||||
|
<button onClick={load} className="text-slate-400 hover:text-slate-700">
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-5 space-y-5">
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatCard label="Open tables" value={stats.open_tables} />
|
||||||
|
<StatCard label="Revenue today" value={stats.today_revenue != null ? `€${stats.today_revenue.toFixed(2)}` : null} color="text-emerald-600" />
|
||||||
|
<StatCard label="Orders today" value={stats.today_orders} />
|
||||||
|
<StatCard label="Online pending" value={stats.online_orders_pending} color={stats.online_orders_pending > 0 ? 'text-amber-600' : 'text-slate-800'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastUpdated && (
|
||||||
|
<p className="text-xs text-slate-400 text-right">
|
||||||
|
Refreshed {lastUpdated.toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.snapshot_taken_at && (() => {
|
||||||
|
const diffMin = Math.round((Date.now() - new Date(stats.snapshot_taken_at).getTime()) / 60_000)
|
||||||
|
const label = diffMin < 1 ? 'just now' : `${diffMin} min ago`
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-slate-400 text-right -mt-3">
|
||||||
|
Site data: {label}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Pending orders */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-bold text-slate-700 flex items-center gap-2">
|
||||||
|
<Bell size={16} className={pending.length ? 'text-amber-500' : 'text-slate-300'} />
|
||||||
|
Pending orders
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/${siteId}/orders`)}
|
||||||
|
className="text-sky-500 text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="w-6 h-6 rounded-full border-2 border-sky-400 border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : pending.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl p-8 text-center text-slate-400 text-sm shadow-sm">
|
||||||
|
No pending orders
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pending.map(o => <OrderCard key={o.id} order={o} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Menu</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Hanken+Grotesk:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3087
connect_frontend/menu-app/package-lock.json
generated
Normal file
3087
connect_frontend/menu-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
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: {} } }
|
||||||
13
connect_frontend/menu-app/src/App.jsx
Normal file
13
connect_frontend/menu-app/src/App.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import MenuPage from './pages/MenuPage'
|
||||||
|
import OrderConfirm from './pages/OrderConfirm'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/:siteSlug" element={<MenuPage />} />
|
||||||
|
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 }
|
||||||
|
}
|
||||||
209
connect_frontend/menu-app/src/components/primitives.jsx
Normal file
209
connect_frontend/menu-app/src/components/primitives.jsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
Leaf, Sprout, Wheat, Flame, Star, ChefHat,
|
||||||
|
Minus, Plus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// ── Money helpers ────────────────────────────────────────────────────────────
|
||||||
|
export function eur(n) {
|
||||||
|
return '€' + Number(n).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discountedPrice(product) {
|
||||||
|
const base = product.digital_price ?? product.base_price ?? product.price ?? 0
|
||||||
|
const pct = product.digital_discount ?? product.discountPct ?? 0
|
||||||
|
if (!pct) return base
|
||||||
|
return Math.round(base * (1 - pct / 100) * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function basePrice(product) {
|
||||||
|
return product.digital_price ?? product.base_price ?? product.price ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDiscount(product) {
|
||||||
|
const pct = product.digital_discount ?? product.discountPct ?? 0
|
||||||
|
return pct > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discountPct(product) {
|
||||||
|
return product.digital_discount ?? product.discountPct ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placeholder gradient art ─────────────────────────────────────────────────
|
||||||
|
export function DishArt({ product, category, size = 'card' }) {
|
||||||
|
const hue = category?.hue ?? 40
|
||||||
|
const GlyphIcon = category?.GlyphIcon ?? null
|
||||||
|
const id = product?.id ?? 'x'
|
||||||
|
let seed = 0
|
||||||
|
for (let i = 0; i < id.length; i++) seed += id.charCodeAt(i)
|
||||||
|
const lift = (seed % 5) - 2
|
||||||
|
const c1 = `hsl(${hue} 34% ${90 + lift}%)`
|
||||||
|
const c2 = `hsl(${hue} 30% ${80 + lift}%)`
|
||||||
|
const glyphColor = `hsl(${hue} 32% 42%)`
|
||||||
|
const firstName = product?.digital_name || product?.name || '?'
|
||||||
|
const letter = typeof firstName === 'object' ? (firstName.en?.[0] ?? '?') : (firstName[0] ?? '?')
|
||||||
|
|
||||||
|
const sizeClass =
|
||||||
|
size === 'hero'
|
||||||
|
? 'self-stretch min-h-[100px] w-[100px]'
|
||||||
|
: size === 'sm'
|
||||||
|
? 'h-16 w-16'
|
||||||
|
: 'h-[92px] w-[92px]'
|
||||||
|
const iconClass = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-[13px]`}
|
||||||
|
style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute -right-2 -top-3 font-display text-[56px] leading-none opacity-[0.14] select-none"
|
||||||
|
style={{ color: glyphColor }}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
{GlyphIcon && (
|
||||||
|
<GlyphIcon
|
||||||
|
className={iconClass}
|
||||||
|
style={{ color: glyphColor, opacity: 0.62 }}
|
||||||
|
strokeWidth={1.4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Badge (Popular / Chef's pick) ────────────────────────────────────────────
|
||||||
|
export function Badge({ kind, t }) {
|
||||||
|
if (kind === 'popular') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[#f4ecd8] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#a9842f] ring-1 ring-inset ring-[#e4d4a8]">
|
||||||
|
<Flame className="h-3 w-3" strokeWidth={2.2} />
|
||||||
|
{t?.popular ?? 'Popular'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind === 'chefs') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[#2d3b2d] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#f0e9d6]">
|
||||||
|
<ChefHat className="h-3 w-3" strokeWidth={2} />
|
||||||
|
{t?.chefs ?? "Chef's pick"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dietary chips (with text labels) ────────────────────────────────────────
|
||||||
|
const DIET_STYLE = {
|
||||||
|
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#c9e2cb' },
|
||||||
|
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#d8e2bd' },
|
||||||
|
'gluten-free': { Icon: Wheat, fg: '#a9842f', bg: '#f5edd8', ring: '#e6d6a6' },
|
||||||
|
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f7e6dc', ring: '#eccab3' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DietChip({ tag, t }) {
|
||||||
|
const s = DIET_STYLE[tag]
|
||||||
|
if (!s) return null
|
||||||
|
const { Icon } = s
|
||||||
|
const label = t?.dietary?.[tag] ?? tag
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium ring-1 ring-inset"
|
||||||
|
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
|
||||||
|
>
|
||||||
|
<Icon className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compact tag icon badges (card title row) ─────────────────────────────────
|
||||||
|
const TAG_BADGE = {
|
||||||
|
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#bcdcc0' },
|
||||||
|
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#cfe0b0' },
|
||||||
|
'gluten-free': { Icon: Wheat, fg: '#9a7726', bg: '#f6edd6', ring: '#e6d49e' },
|
||||||
|
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f8e6da', ring: '#eec4ac' },
|
||||||
|
}
|
||||||
|
const PRIORITY_BADGE = {
|
||||||
|
popular: { Icon: Star, fg: '#a9842f', bg: '#f6edd6', ring: '#e6d49e' },
|
||||||
|
chefs: { Icon: ChefHat, fg: '#f0e9d6', bg: '#2d3b2d', ring: '#2d3b2d' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagIcons({ product }) {
|
||||||
|
const items = []
|
||||||
|
const badge = product.badge ?? product.digital_badge
|
||||||
|
if (badge && PRIORITY_BADGE[badge]) items.push(PRIORITY_BADGE[badge])
|
||||||
|
const tags = product.tags ?? product.digital_tags ?? []
|
||||||
|
tags.forEach(tag => { if (TAG_BADGE[tag]) items.push(TAG_BADGE[tag]) })
|
||||||
|
const shown = items.slice(0, 3)
|
||||||
|
if (!shown.length) return null
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-1 pt-[3px]">
|
||||||
|
{shown.map((s, i) => {
|
||||||
|
const { Icon } = s
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex h-[19px] w-[19px] items-center justify-center rounded-full ring-1 ring-inset"
|
||||||
|
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
|
||||||
|
>
|
||||||
|
<Icon className="h-[11px] w-[11px]" strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Price display ─────────────────────────────────────────────────────────────
|
||||||
|
export function Price({ product, large }) {
|
||||||
|
const base = basePrice(product)
|
||||||
|
const now = discountedPrice(product)
|
||||||
|
const discounted = hasDiscount(product)
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
{discounted && (
|
||||||
|
<span className="font-display text-[13px] text-[#b3aa97] line-through">{eur(base)}</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`font-display ${large ? 'text-[18px]' : 'text-[16px]'} font-semibold ${discounted ? 'text-[#c2602f]' : 'text-[#2d3b2d]'}`}
|
||||||
|
>
|
||||||
|
{eur(now)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discount flag ─────────────────────────────────────────────────────────────
|
||||||
|
export function DiscountFlag({ product, t }) {
|
||||||
|
const pct = discountPct(product)
|
||||||
|
if (!pct) return null
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-[#c2602f] px-1.5 py-[2px] font-sans text-[10px] font-bold tracking-[0.05em] text-white">
|
||||||
|
−{pct}% {t?.off ?? 'OFF'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quantity stepper ─────────────────────────────────────────────────────────
|
||||||
|
export function Stepper({ qty, onInc, onDec }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-full bg-[#f3efe5] p-1 ring-1 ring-inset ring-[#e8e1d1]">
|
||||||
|
<button
|
||||||
|
onClick={onDec}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#2d3b2d] shadow-sm ring-1 ring-[#e8e1d1] transition active:scale-90"
|
||||||
|
>
|
||||||
|
<Minus className="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[16px] text-center font-display text-[16px] font-semibold tabular-nums text-[#2d3b2d]">
|
||||||
|
{qty}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onInc}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#2d3b2d] text-white shadow-sm transition active:scale-90"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
connect_frontend/menu-app/src/index.css
Normal file
18
connect_frontend/menu-app/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Hanken Grotesk', system-ui, sans-serif;
|
||||||
|
background: radial-gradient(ellipse at top, #f3eedf 0%, #ece4d2 50%, #e6ddc8 100%);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.font-display { font-family: 'Bricolage Grotesque', serif; }
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
922
connect_frontend/menu-app/src/pages/MenuPage.jsx
Normal file
922
connect_frontend/menu-app/src/pages/MenuPage.jsx
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
MapPin, Search, Leaf, X, Plus, ShoppingBag,
|
||||||
|
ChevronLeft, ArrowRight, Send, Loader2, Info,
|
||||||
|
Armchair, Check, SearchX, UtensilsCrossed, AlertCircle,
|
||||||
|
Soup, Salad, Wheat, IceCream2, Wine,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { fetchMenu, submitOrder } from '../api'
|
||||||
|
import {
|
||||||
|
DishArt, Badge, DietChip, TagIcons, Price, DiscountFlag, Stepper,
|
||||||
|
eur, discountedPrice, discountPct,
|
||||||
|
} from '../components/primitives'
|
||||||
|
|
||||||
|
// TODO: Replace with real data from API once backend includes restaurant info
|
||||||
|
const RESTAURANT_FALLBACK = {
|
||||||
|
name: 'Our Menu',
|
||||||
|
tagline: { en: 'Kitchen & Bar', gr: 'Κουζίνα & Μπαρ' },
|
||||||
|
blurb: { en: 'Fresh seasonal plates, served with care.', gr: 'Εποχιακά πιάτα, με αγάπη.' },
|
||||||
|
hours: { en: 'Open today · 12:00 – 23:30', gr: 'Ανοιχτά σήμερα · 12:00 – 23:30' },
|
||||||
|
location: { en: '', gr: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category glyph icons — mapped by category id or index
|
||||||
|
const GLYPH_BY_ID = { starters: Soup, salads: Salad, mains: UtensilsCrossed, sides: Wheat, desserts: IceCream2, drinks: Wine }
|
||||||
|
const GLYPH_FALLBACK = [Soup, Salad, UtensilsCrossed, Wheat, IceCream2, Wine]
|
||||||
|
const CATEGORY_HUES = [96, 78, 18, 40, 340, 176]
|
||||||
|
|
||||||
|
// Allergen label fallbacks
|
||||||
|
const ALLERGEN_EN = { gluten: 'gluten', dairy: 'dairy', egg: 'egg', fish: 'fish', shellfish: 'shellfish', nuts: 'nuts', soy: 'soy', sesame: 'sesame', sulphites: 'sulphites' }
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
en: {
|
||||||
|
searchPlaceholder: 'Search the menu…',
|
||||||
|
add: 'Add', back: 'Back', total: 'Total',
|
||||||
|
yourOrder: 'Your order', emptyCart: 'Your cart is empty',
|
||||||
|
emptyCartSub: 'Add a few dishes to get started.',
|
||||||
|
continue: 'Continue', placeOrder: 'Place order', sending: 'Sending…',
|
||||||
|
orderPlaced: 'Order placed!', orderPlacedSub: 'Your order has been sent to the kitchen.',
|
||||||
|
newOrder: 'Back to menu', table: 'Table no.', name: 'Your name',
|
||||||
|
namePh: 'e.g. Alex', tablePh: 'e.g. 12', orderSummary: 'Order summary',
|
||||||
|
contains: 'Contains', ingredients: 'Ingredients', noResults: 'No dishes found',
|
||||||
|
noResultsSub: 'Try a different search.',
|
||||||
|
popular: 'Popular', chefs: "Chef's pick", off: 'OFF',
|
||||||
|
dietary: { vegan: 'Vegan', vegetarian: 'Vegetarian', 'gluten-free': 'Gluten-free', spicy: 'Spicy' },
|
||||||
|
},
|
||||||
|
gr: {
|
||||||
|
searchPlaceholder: 'Αναζήτηση στο μενού…',
|
||||||
|
add: 'Προσθήκη', back: 'Πίσω', total: 'Σύνολο',
|
||||||
|
yourOrder: 'Η παραγγελία σου', emptyCart: 'Το καλάθι σου είναι άδειο',
|
||||||
|
emptyCartSub: 'Πρόσθεσε μερικά πιάτα για να ξεκινήσεις.',
|
||||||
|
continue: 'Συνέχεια', placeOrder: 'Αποστολή παραγγελίας', sending: 'Αποστολή…',
|
||||||
|
orderPlaced: 'Η παραγγελία στάλθηκε!', orderPlacedSub: 'Η παραγγελία σου εστάλη στην κουζίνα.',
|
||||||
|
newOrder: 'Πίσω στο μενού', table: 'Τραπέζι', name: 'Το όνομά σου',
|
||||||
|
namePh: 'π.χ. Αλέξης', tablePh: 'π.χ. 12', orderSummary: 'Σύνοψη παραγγελίας',
|
||||||
|
contains: 'Περιέχει', ingredients: 'Συστατικά', noResults: 'Δεν βρέθηκαν πιάτα',
|
||||||
|
noResultsSub: 'Δοκίμασε διαφορετική αναζήτηση.',
|
||||||
|
popular: 'Δημοφιλές', chefs: 'Επιλογή Σεφ', off: 'ΕΚΠΤΩΣΗ',
|
||||||
|
dietary: { vegan: 'Vegan', vegetarian: 'Χορτοφαγικό', 'gluten-free': 'Χωρίς γλουτένη', spicy: 'Πικάντικο' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normalise a backend product to the shape the UI expects ──────────────────
|
||||||
|
function normaliseProduct(p, catId) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
id: String(p.id),
|
||||||
|
cat: catId,
|
||||||
|
name: p.digital_name || p.name || '',
|
||||||
|
desc: p.digital_description || p.description || '',
|
||||||
|
price: p.digital_price ?? p.base_price ?? 0,
|
||||||
|
badge: p.digital_badge || p.badge || null,
|
||||||
|
tags: p.digital_tags || p.tags || [],
|
||||||
|
allergens: p.allergens || [],
|
||||||
|
ingredients: p.ingredients || null,
|
||||||
|
discountPct: p.digital_discount || p.discountPct || 0,
|
||||||
|
image_url: p.digital_image_url || p.image_url || null,
|
||||||
|
digital_available: p.digital_available !== false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseCategories(rawCats) {
|
||||||
|
return rawCats.map((cat, idx) => {
|
||||||
|
const id = cat.id ? String(cat.id) : `cat-${idx}`
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
id,
|
||||||
|
hue: cat.hue ?? CATEGORY_HUES[idx % CATEGORY_HUES.length],
|
||||||
|
GlyphIcon: GLYPH_BY_ID[id] ?? GLYPH_FALLBACK[idx % GLYPH_FALLBACK.length],
|
||||||
|
products: (cat.products || []).map(p => normaliseProduct(p, id)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bottom Sheet shell ────────────────────────────────────────────────────────
|
||||||
|
function Sheet({ open, onClose, children, maxH = '88vh' }) {
|
||||||
|
if (!open) return null
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 animate-fade bg-[#2d2a1f]/40 backdrop-blur-[2px]"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="relative z-10 w-full sm:max-w-[960px] animate-slideup overflow-hidden rounded-t-[24px] bg-[#faf7f0] shadow-[0_-12px_40px_-12px_rgba(45,42,31,0.4)]"
|
||||||
|
style={{ maxHeight: maxH }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHandle() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center pt-2.5">
|
||||||
|
<div className="h-1 w-10 rounded-full bg-[#ddd5c0]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hero ──────────────────────────────────────────────────────────────────────
|
||||||
|
function Hero({ lang, setLang, restaurant }) {
|
||||||
|
const r = { ...RESTAURANT_FALLBACK, ...restaurant }
|
||||||
|
return (
|
||||||
|
<header className="relative px-6 pt-7 pb-6 text-center">
|
||||||
|
{/* Language toggle */}
|
||||||
|
<div className="absolute right-5 top-6">
|
||||||
|
<div className="flex items-center rounded-full bg-white/70 p-0.5 text-[11px] font-semibold ring-1 ring-[#e3dcc9] backdrop-blur">
|
||||||
|
{['en', 'gr'].map(l => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() => setLang(l)}
|
||||||
|
className={`rounded-full px-2.5 py-1 uppercase tracking-wider transition ${
|
||||||
|
lang === l ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'text-[#8a8266]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l === 'en' ? 'EN' : 'ΕΛ'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.location?.[lang] && (
|
||||||
|
<div className="mx-auto inline-flex items-center gap-1.5 rounded-full bg-white/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#8a7f5e] ring-1 ring-[#e8e1d1]">
|
||||||
|
<MapPin className="h-3 w-3" strokeWidth={2} />
|
||||||
|
{r.location[lang]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="mt-4 font-display text-[42px] font-semibold leading-[0.95] tracking-tight text-[#2d3b2d]">
|
||||||
|
{r.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
|
||||||
|
{r.tagline?.[lang] ?? r.tagline ?? ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ornament */}
|
||||||
|
<div className="mx-auto my-4 flex w-40 items-center gap-2">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
|
||||||
|
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mx-auto max-w-[300px] text-[13px] leading-relaxed text-[#7d7660]">
|
||||||
|
{r.blurb?.[lang] ?? r.blurb ?? ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{r.hours?.[lang] && (
|
||||||
|
<div className="mt-2.5 inline-flex items-center gap-1.5 text-[12px] font-medium text-[#3f7d4e]">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#3f7d4e] opacity-60" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[#3f7d4e]" />
|
||||||
|
</span>
|
||||||
|
{r.hours[lang]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category Bar ──────────────────────────────────────────────────────────────
|
||||||
|
function CategoryBar({ categories, active, onPick, onSearch, lang }) {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
const railRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 220)
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rail = railRef.current
|
||||||
|
if (!rail) return
|
||||||
|
const el = rail.querySelector(`[data-pill="${active}"]`)
|
||||||
|
if (el) {
|
||||||
|
const target = el.offsetLeft - rail.clientWidth / 2 + el.clientWidth / 2
|
||||||
|
rail.scrollTo({ left: target, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sticky top-0 z-30 bg-[#faf7f0]/95 backdrop-blur transition-shadow ${scrolled ? 'shadow-[0_6px_20px_-12px_rgba(45,59,45,0.35)]' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
onClick={onSearch}
|
||||||
|
aria-label="Search"
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-[#2d3b2d] ring-1 ring-[#e8e1d1] transition active:scale-90"
|
||||||
|
>
|
||||||
|
<Search className="h-[18px] w-[18px]" strokeWidth={1.9} />
|
||||||
|
</button>
|
||||||
|
<div ref={railRef} className="flex gap-1.5 overflow-x-auto no-scrollbar py-[3px]">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const on = cat.id === active
|
||||||
|
const label = typeof cat.name === 'object' ? (cat.name[lang] ?? cat.name.en) : cat.name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
data-pill={cat.id}
|
||||||
|
onClick={() => onPick(cat.id)}
|
||||||
|
className={`whitespace-nowrap rounded-full px-3.5 py-1.5 text-[13px] font-medium transition ${
|
||||||
|
on
|
||||||
|
? 'bg-[#2d3b2d] text-[#f0e9d6] shadow-sm'
|
||||||
|
: 'bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product Card ──────────────────────────────────────────────────────────────
|
||||||
|
function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
|
||||||
|
const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name
|
||||||
|
const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc
|
||||||
|
const unavailable = product.digital_available === false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={unavailable ? undefined : () => onOpen(product)}
|
||||||
|
className={`group overflow-hidden rounded-[20px] bg-[#fcfbf7] ring-1 ring-[#e7e1d1] shadow-card transition hover:shadow-card-hover active:scale-[0.992] ${unavailable ? 'opacity-50' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-stretch gap-3.5 p-3.5">
|
||||||
|
{/* Left: photo or placeholder art */}
|
||||||
|
{product.image_url ? (
|
||||||
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt={name}
|
||||||
|
className="w-[100px] min-h-[100px] self-stretch shrink-0 rounded-[13px] object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DishArt product={product} category={category} size="hero" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: content column */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<h3 className="min-w-0 flex-1 font-display text-[19px] font-semibold leading-[1.12] tracking-[-0.01em] text-[#2d3b2d]">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<TagIcons product={product} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1.5 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-[#857e69]">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[#ece4d2] pt-3 mt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Price product={product} large />
|
||||||
|
<DiscountFlag product={product} t={t} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
|
||||||
|
aria-label={t.add}
|
||||||
|
className="flex h-8 items-center gap-1 rounded-full bg-[#2d3b2d] pl-2.5 pr-3 text-[12px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-95 hover:bg-[#26331f]"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" strokeWidth={2.4} />
|
||||||
|
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Menu Section ──────────────────────────────────────────────────────────────
|
||||||
|
function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
|
||||||
|
const { hue, products } = category
|
||||||
|
const label = typeof category.name === 'object' ? (category.name[lang] ?? category.name.en) : category.name
|
||||||
|
return (
|
||||||
|
<section ref={sectionRef} data-section={category.id} className="scroll-mt-[64px] px-3 pt-3">
|
||||||
|
<div
|
||||||
|
className="rounded-[22px] px-3 pb-3 pt-2.5"
|
||||||
|
style={{ background: `linear-gradient(180deg, hsl(${hue} 44% 90% / 0.65) 0%, hsl(${hue} 40% 90% / 0) 62%)` }}
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-baseline justify-between px-2 pt-1">
|
||||||
|
<h2 className="font-sans text-[21px] font-semibold tracking-[-0.01em] text-[#2a2a2a]">{label}</h2>
|
||||||
|
<span className="font-sans text-[15px] font-medium tabular-nums text-[#2a2a2a]/45">{products.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{products.map(p => (
|
||||||
|
<ProductCard
|
||||||
|
key={p.id}
|
||||||
|
product={p}
|
||||||
|
category={category}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onAdd={onAdd}
|
||||||
|
qty={cart[p.id] || 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product Detail Sheet ──────────────────────────────────────────────────────
|
||||||
|
function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec }) {
|
||||||
|
if (!product) return null
|
||||||
|
const hue = category?.hue ?? 40
|
||||||
|
const GlyphIcon = category?.GlyphIcon ?? UtensilsCrossed
|
||||||
|
const name = typeof product.name === 'object' ? (product.name[lang] ?? product.name.en) : product.name
|
||||||
|
const desc = typeof product.desc === 'object' ? (product.desc[lang] ?? product.desc.en) : product.desc
|
||||||
|
const ingredients = product.ingredients
|
||||||
|
? (typeof product.ingredients === 'object' && !Array.isArray(product.ingredients)
|
||||||
|
? (product.ingredients[lang] ?? product.ingredients.en ?? [])
|
||||||
|
: product.ingredients)
|
||||||
|
: []
|
||||||
|
const allergens = product.allergens ?? []
|
||||||
|
const badge = product.badge ?? product.digital_badge
|
||||||
|
const tags = product.tags ?? product.digital_tags ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={!!product} onClose={onClose}>
|
||||||
|
<SheetHandle />
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#6d6a59] ring-1 ring-[#e8e1d1]"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<div className="max-h-[82vh] overflow-y-auto px-5 pb-5">
|
||||||
|
{/* Hero art */}
|
||||||
|
{product.image_url ? (
|
||||||
|
<div className="mt-2 aspect-square w-full overflow-hidden rounded-2xl">
|
||||||
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt={name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mt-2 aspect-square w-full flex items-center justify-center overflow-hidden rounded-2xl"
|
||||||
|
style={{ background: `linear-gradient(135deg, hsl(${hue} 34% 90%), hsl(${hue} 30% 80%))` }}
|
||||||
|
>
|
||||||
|
<GlyphIcon
|
||||||
|
className="h-16 w-16"
|
||||||
|
style={{ color: `hsl(${hue} 32% 42%)`, opacity: 0.6 }}
|
||||||
|
strokeWidth={1.2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{badge && <Badge kind={badge} t={t} />}
|
||||||
|
<DiscountFlag product={product} t={t} />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 font-display text-[28px] font-semibold leading-tight text-[#2d3b2d]">{name}</h2>
|
||||||
|
{desc && <p className="mt-1 text-[14px] leading-relaxed text-[#7d7660]">{desc}</p>}
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{tags.map(tag => <DietChip key={tag} tag={tag} t={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ingredients.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="font-sans text-[11px] font-semibold uppercase tracking-[0.14em] text-[#b3aa90]">{t.ingredients}</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<span key={i} className="rounded-lg bg-white px-2.5 py-1 text-[12.5px] text-[#5f5a48] ring-1 ring-[#ece5d5]">{ing}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allergens.length > 0 && (
|
||||||
|
<div className="mt-4 flex items-start gap-2 rounded-xl bg-[#f7e6dc]/60 px-3 py-2.5 ring-1 ring-[#eccab3]">
|
||||||
|
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[#c2602f]" strokeWidth={2} />
|
||||||
|
<div className="text-[12.5px] leading-snug text-[#9a5732]">
|
||||||
|
<span className="font-semibold">{t.contains}: </span>
|
||||||
|
{allergens.map(a => ALLERGEN_EN[a] ?? a).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky add bar */}
|
||||||
|
<div className="flex items-center gap-3 border-t border-[#ece5d5] bg-[#faf7f0] px-5 py-3.5">
|
||||||
|
{qty > 0 ? (
|
||||||
|
<Stepper qty={qty} onInc={() => onInc(product)} onDec={() => onDec(product)} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<Price product={product} large />
|
||||||
|
<DiscountFlag product={product} t={t} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { onAdd(product); onClose() }}
|
||||||
|
className="ml-auto flex h-11 flex-1 items-center justify-center gap-2 rounded-full bg-[#2d3b2d] px-5 text-[14px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-[0.98] hover:bg-[#26331f]"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" strokeWidth={2.4} />
|
||||||
|
{t.add} · {eur(discountedPrice(product))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search Overlay ────────────────────────────────────────────────────────────
|
||||||
|
function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart }) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
useEffect(() => { if (open && inputRef.current) inputRef.current.focus() }, [open])
|
||||||
|
useEffect(() => { if (!open) setQ('') }, [open])
|
||||||
|
|
||||||
|
const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories])
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
if (!term) return []
|
||||||
|
return allProducts.filter(p => {
|
||||||
|
const name = typeof p.name === 'object' ? Object.values(p.name).join(' ') : (p.name ?? '')
|
||||||
|
const desc = typeof p.desc === 'object' ? Object.values(p.desc).join(' ') : (p.desc ?? '')
|
||||||
|
const ings = p.ingredients
|
||||||
|
? (Array.isArray(p.ingredients) ? p.ingredients : Object.values(p.ingredients).flat()).join(' ')
|
||||||
|
: ''
|
||||||
|
return (name + ' ' + desc + ' ' + ings).toLowerCase().includes(term)
|
||||||
|
})
|
||||||
|
}, [q, allProducts])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 mx-auto flex w-full sm:max-w-[960px] flex-col bg-[#faf7f0] animate-fade">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-3">
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-full bg-white px-3.5 py-2.5 ring-1 ring-[#e8e1d1]">
|
||||||
|
<Search className="h-[18px] w-[18px] text-[#b3aa90]" strokeWidth={1.9} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder={t.searchPlaceholder}
|
||||||
|
className="w-full bg-transparent text-[15px] text-[#2d3b2d] outline-none placeholder:text-[#b3aa90]"
|
||||||
|
/>
|
||||||
|
{q && (
|
||||||
|
<button onClick={() => setQ('')}>
|
||||||
|
<X className="h-4 w-4 text-[#b3aa90]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="px-1 text-[14px] font-medium text-[#6d6a59]">
|
||||||
|
{t.back}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-6">
|
||||||
|
{q && results.length === 0 && (
|
||||||
|
<div className="mt-24 text-center">
|
||||||
|
<SearchX className="mx-auto h-10 w-10 text-[#cfc6ad]" strokeWidth={1.4} />
|
||||||
|
<div className="mt-3 font-display text-[20px] text-[#2d3b2d]">{t.noResults}</div>
|
||||||
|
<div className="mt-1 text-[13px] text-[#9a917a]">{t.noResultsSub}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 pt-1">
|
||||||
|
{results.map(p => {
|
||||||
|
const cat = categories.find(c => c.id === p.cat)
|
||||||
|
return (
|
||||||
|
<ProductCard
|
||||||
|
key={p.id}
|
||||||
|
product={p}
|
||||||
|
category={cat}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onOpen={prod => { onClose(); onOpen(prod) }}
|
||||||
|
onAdd={onAdd}
|
||||||
|
qty={cart[p.id] || 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!q && (
|
||||||
|
<div className="mt-24 text-center text-[#b3aa90]">
|
||||||
|
<UtensilsCrossed className="mx-auto h-10 w-10" strokeWidth={1.3} />
|
||||||
|
<div className="mt-3 text-[13px]">{t.searchPlaceholder}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cart Flow (bottom sheet) ──────────────────────────────────────────────────
|
||||||
|
function CartFlow({ stage, setStage, cart, setCart, categories, lang, t, onOpenProduct, siteSlug, navigate }) {
|
||||||
|
const [form, setForm] = useState({ name: '', table: '' })
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
const allProducts = useMemo(() => categories.flatMap(c => c.products), [categories])
|
||||||
|
|
||||||
|
const lines = useMemo(() =>
|
||||||
|
Object.entries(cart)
|
||||||
|
.map(([id, qty]) => ({ product: allProducts.find(p => p.id === id), qty }))
|
||||||
|
.filter(l => l.product && l.qty > 0),
|
||||||
|
[cart, allProducts]
|
||||||
|
)
|
||||||
|
const total = lines.reduce((s, l) => s + discountedPrice(l.product) * l.qty, 0)
|
||||||
|
|
||||||
|
const inc = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
|
||||||
|
const dec = p => setCart(c => {
|
||||||
|
const n = (c[p.id] || 0) - 1
|
||||||
|
const next = { ...c }
|
||||||
|
if (n <= 0) delete next[p.id]; else next[p.id] = n
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const result = await submitOrder(siteSlug, {
|
||||||
|
order_type: 'dine_in',
|
||||||
|
customer_name: form.name.trim(),
|
||||||
|
customer_table: form.table.trim(),
|
||||||
|
items: lines.map(l => ({
|
||||||
|
product_id: l.product.id,
|
||||||
|
name: typeof l.product.name === 'object' ? l.product.name.en : l.product.name,
|
||||||
|
quantity: l.qty,
|
||||||
|
unit_price: discountedPrice(l.product),
|
||||||
|
})),
|
||||||
|
subtotal: Math.round(total * 100) / 100,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
lang,
|
||||||
|
placed_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
setCart({})
|
||||||
|
setForm({ name: '', table: '' })
|
||||||
|
setStage(null)
|
||||||
|
navigate(`/${siteSlug}/confirm/${result.public_ref}`)
|
||||||
|
} catch {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => setStage(null)
|
||||||
|
|
||||||
|
// Cart view
|
||||||
|
if (stage === 'cart') {
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={close}>
|
||||||
|
<SheetHandle />
|
||||||
|
<div className="flex items-center justify-between px-5 pb-1 pt-3">
|
||||||
|
<h2 className="font-display text-[24px] font-semibold text-[#2d3b2d]">{t.yourOrder}</h2>
|
||||||
|
<button onClick={close} className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]">
|
||||||
|
<X className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center px-6 py-16 text-center">
|
||||||
|
<ShoppingBag className="h-12 w-12 text-[#cfc6ad]" strokeWidth={1.3} />
|
||||||
|
<div className="mt-4 font-display text-[20px] text-[#2d3b2d]">{t.emptyCart}</div>
|
||||||
|
<div className="mt-1 text-[13px] text-[#9a917a]">{t.emptyCartSub}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-[56vh] overflow-y-auto px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{lines.map(l => {
|
||||||
|
const cat = categories.find(c => c.id === l.product.cat)
|
||||||
|
const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name
|
||||||
|
return (
|
||||||
|
<div key={l.product.id} className="flex items-center gap-3 rounded-2xl bg-white p-2.5 ring-1 ring-[#ece5d5]">
|
||||||
|
<DishArt product={l.product} category={cat} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
onClick={() => { close(); onOpenProduct(l.product) }}
|
||||||
|
className="cursor-pointer font-display text-[16px] font-semibold leading-tight text-[#2d3b2d]"
|
||||||
|
>
|
||||||
|
{pname}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-display text-[14px] text-[#857e69]">{eur(discountedPrice(l.product))}</div>
|
||||||
|
</div>
|
||||||
|
<Stepper qty={l.qty} onInc={() => inc(l.product)} onDec={() => dec(l.product)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[#ece5d5] px-5 py-3.5">
|
||||||
|
<div className="mb-2.5 flex items-baseline justify-between">
|
||||||
|
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
|
||||||
|
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setStage('checkout')}
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-[#2d3b2d] text-[15px] font-semibold text-[#f0e9d6] transition active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t.continue}<ArrowRight className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout view
|
||||||
|
if (stage === 'checkout') {
|
||||||
|
const valid = form.name.trim() && form.table.trim()
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={close}>
|
||||||
|
<SheetHandle />
|
||||||
|
<div className="flex items-center gap-3 px-5 pb-1 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setStage('cart')}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[#6d6a59] ring-1 ring-[#e8e1d1]"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<h2 className="font-display text-[22px] font-semibold text-[#2d3b2d]">{t.placeOrder}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[62vh] overflow-y-auto px-5 pb-2 pt-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.name}</span>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder={t.namePh}
|
||||||
|
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.table}</span>
|
||||||
|
<input
|
||||||
|
value={form.table}
|
||||||
|
onChange={e => setForm(f => ({ ...f, table: e.target.value }))}
|
||||||
|
placeholder={t.tablePh}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="mt-1.5 w-full rounded-xl bg-white px-3.5 py-3 text-[15px] text-[#2d3b2d] outline-none ring-1 ring-[#e8e1d1] focus:ring-2 focus:ring-[#9caf88]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#9a917a]">{t.orderSummary}</div>
|
||||||
|
<div className="mt-2 overflow-hidden rounded-2xl bg-white ring-1 ring-[#ece5d5]">
|
||||||
|
{lines.map((l, i) => {
|
||||||
|
const pname = typeof l.product.name === 'object' ? (l.product.name[lang] ?? l.product.name.en) : l.product.name
|
||||||
|
return (
|
||||||
|
<div key={l.product.id} className={`flex items-center justify-between px-4 py-2.5 ${i > 0 ? 'border-t border-[#f0ebdd]' : ''}`}>
|
||||||
|
<span className="text-[13.5px] text-[#5f5a48]">
|
||||||
|
<b className="font-display text-[#2d3b2d]">{l.qty}×</b> {pname}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[14px] font-semibold tabular-nums text-[#2d3b2d]">
|
||||||
|
{eur(discountedPrice(l.product) * l.qty)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[#ece5d5] px-5 py-3.5">
|
||||||
|
<div className="mb-2.5 flex items-baseline justify-between">
|
||||||
|
<span className="text-[13px] font-medium text-[#7d7660]">{t.total}</span>
|
||||||
|
<span className="font-display text-[24px] font-semibold tabular-nums text-[#2d3b2d]">{eur(total)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={!valid || sending}
|
||||||
|
onClick={submit}
|
||||||
|
className={`flex h-12 w-full items-center justify-center gap-2 rounded-full text-[15px] font-semibold transition active:scale-[0.98] ${
|
||||||
|
valid && !sending ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'bg-[#d8d2c1] text-[#a39c87] cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? <><Loader2 className="h-4 w-4 animate-spin" /> {t.sending}</>
|
||||||
|
: <><Send className="h-4 w-4" strokeWidth={2} /> {t.placeOrder}</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Floating Cart Button ──────────────────────────────────────────────────────
|
||||||
|
function CartButton({ count, total, t, onClick }) {
|
||||||
|
const [bump, setBump] = useState(false)
|
||||||
|
const prev = useRef(count)
|
||||||
|
useEffect(() => {
|
||||||
|
if (count !== prev.current && count > 0) {
|
||||||
|
setBump(true)
|
||||||
|
const tm = setTimeout(() => setBump(false), 320)
|
||||||
|
prev.current = count
|
||||||
|
return () => clearTimeout(tm)
|
||||||
|
}
|
||||||
|
prev.current = count
|
||||||
|
}, [count])
|
||||||
|
|
||||||
|
if (count === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 mx-auto w-full sm:max-w-[960px] px-4 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`pointer-events-auto flex h-14 w-full items-center justify-between rounded-full bg-[#2d3b2d] px-5 text-[#f0e9d6] shadow-cart transition active:scale-[0.98] ${bump ? 'animate-pop' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
<span className="relative">
|
||||||
|
<ShoppingBag className="h-[22px] w-[22px]" strokeWidth={1.8} />
|
||||||
|
<span className="absolute -right-2 -top-2 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-[#c9a24b] px-1 text-[11px] font-bold text-[#2d3b2d] tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] font-semibold">{t.yourOrder}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[18px] font-semibold tabular-nums">{eur(total)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main MenuPage ─────────────────────────────────────────────────────────────
|
||||||
|
export default function MenuPage() {
|
||||||
|
const { siteSlug } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [restaurant, setRestaurant] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const [lang, setLang] = useState(() => {
|
||||||
|
try { return localStorage.getItem('ot_lang') || 'en' } catch { return 'en' }
|
||||||
|
})
|
||||||
|
const [cart, setCart] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('ot_cart')) || {} } catch { return {} }
|
||||||
|
})
|
||||||
|
const [active, setActive] = useState(null)
|
||||||
|
const [activeProduct, setActiveProduct] = useState(null)
|
||||||
|
const [activeProductCat, setActiveProductCat] = useState(null)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [stage, setStage] = useState(null)
|
||||||
|
|
||||||
|
const t = I18N[lang] ?? I18N.en
|
||||||
|
|
||||||
|
useEffect(() => { try { localStorage.setItem('ot_lang', lang) } catch {} }, [lang])
|
||||||
|
useEffect(() => { try { localStorage.setItem('ot_cart', JSON.stringify(cart)) } catch {} }, [cart])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMenu(siteSlug)
|
||||||
|
.then(data => {
|
||||||
|
const cats = normaliseCategories(data.categories || [])
|
||||||
|
setCategories(cats)
|
||||||
|
setRestaurant(data.restaurant ?? null)
|
||||||
|
if (cats.length) setActive(cats[0].id)
|
||||||
|
})
|
||||||
|
.catch(() => setError('Menu not available. Please try again.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [siteSlug])
|
||||||
|
|
||||||
|
const sectionRefs = useRef({})
|
||||||
|
const clickLock = useRef(false)
|
||||||
|
|
||||||
|
// Scroll-spy
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
if (clickLock.current) return
|
||||||
|
let current = categories[0]?.id
|
||||||
|
for (const c of categories) {
|
||||||
|
const el = sectionRefs.current[c.id]
|
||||||
|
if (el && el.getBoundingClientRect().top <= 90) current = c.id
|
||||||
|
}
|
||||||
|
if (current) setActive(a => a === current ? a : current)
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
onScroll()
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
|
const pick = id => {
|
||||||
|
const el = sectionRefs.current[id]
|
||||||
|
if (!el) return
|
||||||
|
setActive(id)
|
||||||
|
clickLock.current = true
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - 64
|
||||||
|
window.scrollTo({ top, behavior: 'smooth' })
|
||||||
|
setTimeout(() => { clickLock.current = false }, 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
|
||||||
|
const incCart = p => setCart(c => ({ ...c, [p.id]: (c[p.id] || 0) + 1 }))
|
||||||
|
const decCart = p => setCart(c => {
|
||||||
|
const n = (c[p.id] || 0) - 1
|
||||||
|
const next = { ...c }
|
||||||
|
if (n <= 0) delete next[p.id]; else next[p.id] = n
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProduct = p => {
|
||||||
|
setActiveProduct(p)
|
||||||
|
setActiveProductCat(categories.find(c => c.id === p.cat) ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Object.values(cart).reduce((s, q) => s + q, 0)
|
||||||
|
const total = useMemo(() => {
|
||||||
|
const allProducts = categories.flatMap(c => c.products)
|
||||||
|
return Object.entries(cart).reduce((s, [id, q]) => {
|
||||||
|
const p = allProducts.find(x => x.id === id)
|
||||||
|
return p ? s + discountedPrice(p) * q : s
|
||||||
|
}, 0)
|
||||||
|
}, [cart, categories])
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center bg-[#faf7f0]">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div className="min-h-dvh flex flex-col items-center justify-center gap-3 p-8 text-center bg-[#faf7f0]">
|
||||||
|
<AlertCircle className="text-[#c2602f]" size={40} />
|
||||||
|
<p className="text-[#7d7660]">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto min-h-dvh w-full sm:max-w-[960px] bg-[#faf7f0] sm:shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)]">
|
||||||
|
<Hero lang={lang} setLang={setLang} restaurant={restaurant} />
|
||||||
|
<CategoryBar
|
||||||
|
categories={categories}
|
||||||
|
active={active}
|
||||||
|
onPick={pick}
|
||||||
|
onSearch={() => setSearchOpen(true)}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="pb-32">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<Section
|
||||||
|
key={cat.id}
|
||||||
|
category={cat}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onOpen={openProduct}
|
||||||
|
onAdd={addToCart}
|
||||||
|
cart={cart}
|
||||||
|
sectionRef={el => { sectionRefs.current[cat.id] = el }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<footer className="mt-10 px-6 text-center">
|
||||||
|
<div className="mx-auto flex w-32 items-center gap-2">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
|
||||||
|
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />
|
||||||
|
|
||||||
|
<ProductSheet
|
||||||
|
product={activeProduct}
|
||||||
|
category={activeProductCat}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onClose={() => setActiveProduct(null)}
|
||||||
|
onAdd={addToCart}
|
||||||
|
qty={activeProduct ? (cart[activeProduct.id] || 0) : 0}
|
||||||
|
onInc={incCart}
|
||||||
|
onDec={decCart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchOverlay
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
categories={categories}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onOpen={openProduct}
|
||||||
|
onAdd={addToCart}
|
||||||
|
cart={cart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CartFlow
|
||||||
|
stage={stage}
|
||||||
|
setStage={setStage}
|
||||||
|
cart={cart}
|
||||||
|
setCart={setCart}
|
||||||
|
categories={categories}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
onOpenProduct={openProduct}
|
||||||
|
siteSlug={siteSlug}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
connect_frontend/menu-app/src/pages/OrderConfirm.jsx
Normal file
115
connect_frontend/menu-app/src/pages/OrderConfirm.jsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { Check, Clock, X, ChefHat, Truck, Package, Leaf } from 'lucide-react'
|
||||||
|
import { fetchOrderStatus } from '../api'
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: '#c9a24b', bg: '#f6edd6' },
|
||||||
|
accepted: { label: 'Order accepted!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
|
rejected: { label: 'Order declined', Icon: X, color: '#c2602f', bg: '#f7e6dc' },
|
||||||
|
preparing: { label: 'Being prepared', Icon: ChefHat, color: '#2d3b2d', bg: '#e7ede7' },
|
||||||
|
ready: { label: 'Ready for pickup!', Icon: Package, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
|
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: '#2d3b2d', bg: '#e7ede7' },
|
||||||
|
delivered: { label: 'Delivered!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL = new Set(['rejected', 'delivered'])
|
||||||
|
const POLL_MS = 10_000
|
||||||
|
|
||||||
|
export default function OrderConfirm() {
|
||||||
|
const { ref } = useParams()
|
||||||
|
const [orderStatus, setOrderStatus] = useState(null)
|
||||||
|
const [rejectionReason, setRejectionReason] = useState(null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const data = await fetchOrderStatus(ref)
|
||||||
|
setOrderStatus(data.status)
|
||||||
|
setRejectionReason(data.rejection_reason)
|
||||||
|
if (!TERMINAL.has(data.status)) {
|
||||||
|
timer = setTimeout(poll, POLL_MS)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll()
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [ref])
|
||||||
|
|
||||||
|
const cfg = STATUS_CONFIG[orderStatus] ?? STATUS_CONFIG.pending_acceptance
|
||||||
|
const { label, Icon, color, bg } = cfg
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto min-h-dvh max-w-[480px] bg-[#faf7f0] shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)] flex flex-col items-center justify-center px-6 py-12">
|
||||||
|
|
||||||
|
{/* Ornament top */}
|
||||||
|
<div className="mb-8 flex w-32 items-center gap-2">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
|
||||||
|
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-full"
|
||||||
|
style={{ background: bg }}
|
||||||
|
>
|
||||||
|
<Icon size={38} style={{ color }} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ref number */}
|
||||||
|
<p className="mt-5 font-sans text-[11px] font-semibold uppercase tracking-[0.22em] text-[#b3aa90]">
|
||||||
|
{ref}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Status label */}
|
||||||
|
<h1 className="mt-2 font-display text-[28px] font-semibold leading-tight text-center text-[#2d3b2d]">
|
||||||
|
{error ? 'Could not load status' : label}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Rejection reason */}
|
||||||
|
{rejectionReason && (
|
||||||
|
<p className="mt-2 text-[14px] text-center text-[#7d7660]">
|
||||||
|
Reason: {rejectionReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtext */}
|
||||||
|
{orderStatus === 'delivered' && (
|
||||||
|
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
|
||||||
|
Thank you for your order! Enjoy your meal.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{orderStatus === 'rejected' && (
|
||||||
|
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
|
||||||
|
We're sorry we couldn't take your order this time. Please try again or speak to staff.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!TERMINAL.has(orderStatus) && !error && (
|
||||||
|
<p className="mt-3 text-[13px] text-[#9a917a]">
|
||||||
|
We'll update this page automatically. Keep it open.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spinner */}
|
||||||
|
{!TERMINAL.has(orderStatus) && !error && (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<div className="h-5 w-5 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ornament bottom */}
|
||||||
|
<div className="mt-10 flex w-32 items-center gap-2">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
|
||||||
|
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
connect_frontend/menu-app/tailwind.config.js
Normal file
52
connect_frontend/menu-app/tailwind.config.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: ['Bricolage Grotesque', 'Noto Sans', 'system-ui', 'sans-serif'],
|
||||||
|
sans: ['Hanken Grotesk', 'Noto Sans', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
dark: '#2d3b2d',
|
||||||
|
hover: '#26331f',
|
||||||
|
},
|
||||||
|
cream: '#faf7f0',
|
||||||
|
card: '#fcfbf7',
|
||||||
|
gold: '#c9a24b',
|
||||||
|
terracotta: '#c2602f',
|
||||||
|
sage: '#9caf88',
|
||||||
|
success: '#3f7d4e',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
card: '20px',
|
||||||
|
section: '22px',
|
||||||
|
sheet: '24px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 5px 16px -10px rgba(45,42,31,0.45)',
|
||||||
|
'card-hover': '0 10px 24px -12px rgba(45,42,31,0.5)',
|
||||||
|
cart: '0 12px 28px -8px rgba(45,59,45,0.55)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||||
|
slideup: {
|
||||||
|
from: { transform: 'translateY(100%)' },
|
||||||
|
to: { transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
pop: {
|
||||||
|
'0%': { transform: 'scale(1)' },
|
||||||
|
'50%': { transform: 'scale(1.06)' },
|
||||||
|
'100%': { transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
fade: 'fade 0.2s ease',
|
||||||
|
slideup: 'slideup 0.28s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
pop: 'pop 0.32s ease',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,14 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cloud_backend
|
- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
sysadmin_panel/package-lock.json
generated
10
sysadmin_panel/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
@@ -2472,6 +2473,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
|||||||
@@ -22,3 +22,16 @@ client.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default client
|
export default client
|
||||||
|
|
||||||
|
// ── Remote Manager accounts ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getManagersBySite = (siteId) =>
|
||||||
|
client.get(`/api/manager/by-site/${siteId}`)
|
||||||
|
|
||||||
|
export const addManagerToSite = (email, fullName, password, siteId) =>
|
||||||
|
client.post('/api/manager/register', {
|
||||||
|
email, full_name: fullName, password, site_ids: [siteId],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const removeManagerSiteAccess = (managerId, siteId) =>
|
||||||
|
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import { QRCodeCanvas } from 'qrcode.react'
|
||||||
|
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
|
||||||
import LicenseStatus from '../components/LicenseStatus'
|
import LicenseStatus from '../components/LicenseStatus'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
|
|
||||||
@@ -26,12 +27,21 @@ export default function SiteDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
|
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
|
||||||
const [lockReason, setLockReason] = useState('')
|
const [lockReason, setLockReason] = useState('')
|
||||||
const [newExpiry, setNewExpiry] = useState('')
|
const [newExpiry, setNewExpiry] = useState('')
|
||||||
const [newDomain, setNewDomain] = useState('')
|
const [newDomain, setNewDomain] = useState('')
|
||||||
const [acting, setActing] = useState(false)
|
const [acting, setActing] = useState(false)
|
||||||
|
|
||||||
|
// Remote Managers state
|
||||||
|
const [managers, setManagers] = useState([])
|
||||||
|
const [managersLoading, setManagersLoading] = useState(false)
|
||||||
|
const [newMgrEmail, setNewMgrEmail] = useState('')
|
||||||
|
const [newMgrName, setNewMgrName] = useState('')
|
||||||
|
const [newMgrPass, setNewMgrPass] = useState('')
|
||||||
|
const [addingMgr, setAddingMgr] = useState(false)
|
||||||
|
const [removingMgrId, setRemovingMgrId] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +57,49 @@ export default function SiteDetailPage() {
|
|||||||
load()
|
load()
|
||||||
}, [siteId])
|
}, [siteId])
|
||||||
|
|
||||||
|
const fetchManagers = useCallback(async () => {
|
||||||
|
setManagersLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await getManagersBySite(siteId)
|
||||||
|
setManagers(data)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load managers')
|
||||||
|
} finally {
|
||||||
|
setManagersLoading(false)
|
||||||
|
}
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchManagers() }, [fetchManagers])
|
||||||
|
|
||||||
|
async function doAddManager() {
|
||||||
|
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
|
||||||
|
setAddingMgr(true)
|
||||||
|
try {
|
||||||
|
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), siteId)
|
||||||
|
toast.success('Manager added')
|
||||||
|
setModal(null)
|
||||||
|
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
|
||||||
|
fetchManagers()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to add manager')
|
||||||
|
} finally {
|
||||||
|
setAddingMgr(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRemoveManager(managerId) {
|
||||||
|
setRemovingMgrId(managerId)
|
||||||
|
try {
|
||||||
|
await removeManagerSiteAccess(managerId, siteId)
|
||||||
|
toast.success('Access removed')
|
||||||
|
setManagers(prev => prev.filter(m => m.id !== managerId))
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to remove access')
|
||||||
|
} finally {
|
||||||
|
setRemovingMgrId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doLock() {
|
async function doLock() {
|
||||||
if (!lockReason.trim()) return
|
if (!lockReason.trim()) return
|
||||||
setActing(true)
|
setActing(true)
|
||||||
@@ -270,6 +323,99 @@ export default function SiteDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Codes */}
|
||||||
|
{(() => {
|
||||||
|
const slug = site.site_id
|
||||||
|
const menuUrl = `http://72.61.191.197:3100/menu/${slug}`
|
||||||
|
const orderUrl = `http://72.61.191.197:3100/menu/${slug}/order`
|
||||||
|
|
||||||
|
function handleDownload(canvasId, filename) {
|
||||||
|
const canvas = document.getElementById(canvasId)
|
||||||
|
if (!canvas) return
|
||||||
|
const url = canvas.toDataURL('image/png')
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">QR Codes</h2>
|
||||||
|
<div className="flex flex-wrap gap-8">
|
||||||
|
{[
|
||||||
|
{ id: `qr-menu-${site.id}`, url: menuUrl, label: 'Menu QR Code', file: `menu-qr-${slug}.png` },
|
||||||
|
{ id: `qr-order-${site.id}`, url: orderUrl, label: 'Order QR Code', file: `order-qr-${slug}.png` },
|
||||||
|
].map(({ id, url, label, file }) => (
|
||||||
|
<div key={id} className="flex flex-col items-center gap-3">
|
||||||
|
<p className="text-xs font-medium text-gray-400">{label}</p>
|
||||||
|
<div className="bg-white p-3 rounded-xl">
|
||||||
|
<QRCodeCanvas id={id} value={url} size={180} includeMargin={false} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 font-mono break-all max-w-[220px] text-center">{url}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(id, file)}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Remote Managers */}
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal('add_manager')}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Manager
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{managersLoading && (
|
||||||
|
<p className="text-xs text-gray-500">Loading…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!managersLoading && managers.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!managersLoading && managers.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{managers.map(mgr => (
|
||||||
|
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
|
||||||
|
{mgr.full_name && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{mgr.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => doRemoveManager(mgr.id)}
|
||||||
|
disabled={removingMgrId === mgr.id}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{removingMgrId === mgr.id ? '…' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{modal === 'lock' && (
|
{modal === 'lock' && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -349,6 +495,48 @@ export default function SiteDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modal === 'add_manager' && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Add Remote Manager"
|
||||||
|
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
|
||||||
|
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
|
||||||
|
onConfirm={doAddManager}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newMgrEmail}
|
||||||
|
onChange={e => setNewMgrEmail(e.target.value)}
|
||||||
|
placeholder="manager@example.com"
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMgrName}
|
||||||
|
onChange={e => setNewMgrName(e.target.value)}
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newMgrPass}
|
||||||
|
onChange={e => setNewMgrPass(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user