Compare commits
12 Commits
b45a93a559
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a53cc3d66 | |||
| 6c7df8d011 | |||
| a73f081ca1 | |||
| f5736b85cb | |||
| d87540e08f | |||
| 0cad6a76d3 | |||
| a6f759bf49 | |||
| ffaeab136d | |||
| 17fb3a2589 | |||
| 70c7b9564b | |||
| 82b853369b | |||
| ac58d150ea |
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from config import settings
|
||||
from database import engine, Base
|
||||
@@ -11,6 +13,7 @@ import models.menu_snapshot # noqa: F401
|
||||
import models.online_order # noqa: F401
|
||||
import models.manager_account # noqa: F401
|
||||
import models.stats_snapshot # noqa: F401
|
||||
import models.product_image # noqa: F401
|
||||
|
||||
from routers import auth, sites, heartbeat
|
||||
from routers import menu as menu_router
|
||||
@@ -39,6 +42,17 @@ def _run_migrations():
|
||||
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",
|
||||
# QR menu branding/config
|
||||
"ALTER TABLE sites ADD COLUMN menu_mode VARCHAR NOT NULL DEFAULT 'order'",
|
||||
"ALTER TABLE sites ADD COLUMN menu_tagline_en VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_tagline_gr VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_hours_en VARCHAR", # superseded by menu_hours, kept for history
|
||||
"ALTER TABLE sites ADD COLUMN menu_hours_gr VARCHAR", # superseded by menu_hours, kept for history
|
||||
"ALTER TABLE sites ADD COLUMN menu_header_image_url VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_display_name VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_blurb_en VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_blurb_gr VARCHAR",
|
||||
"ALTER TABLE sites ADD COLUMN menu_hours VARCHAR",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
@@ -74,6 +88,12 @@ app.include_router(orders_router.router, prefix="/api/orders", tags=
|
||||
app.include_router(manager_auth_router.router, prefix="/api/manager", tags=["manager"])
|
||||
app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags=["remote"])
|
||||
|
||||
os.makedirs("/app/data/site_headers", exist_ok=True)
|
||||
app.mount("/static/site_headers", StaticFiles(directory="/app/data/site_headers"), name="site_headers")
|
||||
|
||||
os.makedirs("/app/data/product_images", exist_ok=True)
|
||||
app.mount("/static/product_images", StaticFiles(directory="/app/data/product_images"), name="product_images")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
|
||||
19
cloud_backend/models/product_image.py
Normal file
19
cloud_backend/models/product_image.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
"""Cloud-hosted copy of a local product's image, pushed by local_backend
|
||||
during menu sync. Used as a fallback on the public QR menu when the
|
||||
product has no digital_image_url override set."""
|
||||
__tablename__ = "product_images"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True)
|
||||
product_id = Column(Integer, nullable=False)
|
||||
image_url = Column(String, nullable=False)
|
||||
image_hash = Column(String, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (UniqueConstraint("site_id", "product_id", name="uq_product_images_site_product"),)
|
||||
@@ -23,3 +23,13 @@ class Site(Base):
|
||||
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)
|
||||
|
||||
# QR menu branding/config
|
||||
menu_mode = Column(String, nullable=False, default="order") # "order" | "view_only"
|
||||
menu_display_name = Column(String, nullable=True)
|
||||
menu_tagline_en = Column(String, nullable=True)
|
||||
menu_tagline_gr = Column(String, nullable=True)
|
||||
menu_blurb_en = Column(String, nullable=True)
|
||||
menu_blurb_gr = Column(String, nullable=True)
|
||||
menu_hours = Column(String, nullable=True)
|
||||
menu_header_image_url = Column(String, nullable=True)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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
|
||||
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 (
|
||||
@@ -56,10 +58,18 @@ def register_manager(
|
||||
db: Session = Depends(get_db),
|
||||
_admin=Depends(get_current_admin),
|
||||
):
|
||||
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
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
|
||||
|
||||
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
|
||||
manager = ManagerAccount(
|
||||
email=body.email,
|
||||
password_hash=_pwd.hash(body.password),
|
||||
@@ -89,3 +99,52 @@ def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
|
||||
@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()
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
import os
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, UploadFile, File, Form, 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 models.product_image import ProductImage
|
||||
from schemas.menu import MenuSyncRequest, MenuSyncResponse
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
PRODUCT_IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _require_site(
|
||||
x_site_id: str = Header(..., alias="X-Site-ID"),
|
||||
@@ -36,7 +41,30 @@ def get_menu(site_slug: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="No menu published yet")
|
||||
|
||||
import json
|
||||
return json.loads(snapshot.snapshot_json)
|
||||
data = json.loads(snapshot.snapshot_json)
|
||||
data["menu_mode"] = site.menu_mode
|
||||
data["restaurant"] = {
|
||||
"name": site.menu_display_name,
|
||||
"tagline": {"en": site.menu_tagline_en, "gr": site.menu_tagline_gr},
|
||||
"blurb": {"en": site.menu_blurb_en, "gr": site.menu_blurb_gr},
|
||||
"hours": site.menu_hours,
|
||||
"headerImageUrl": site.menu_header_image_url,
|
||||
}
|
||||
|
||||
cloud_images = {
|
||||
img.product_id: img.image_url
|
||||
for img in db.query(ProductImage).filter(ProductImage.site_id == site.id).all()
|
||||
}
|
||||
for cat in data.get("categories", []):
|
||||
for product in cat.get("products", []):
|
||||
# image_url as pushed by local_backend points at the restaurant's own
|
||||
# LAN/local server and isn't reachable from the internet — always
|
||||
# replace it with the cloud-hosted copy (or None) unless a manual
|
||||
# digital_image_url override is set.
|
||||
if not product.get("digital_image_url"):
|
||||
product["image_url"] = cloud_images.get(product.get("id"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ── Internal (site API key) ───────────────────────────────────────────────────
|
||||
@@ -52,3 +80,54 @@ def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Se
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
return MenuSyncResponse(ok=True)
|
||||
|
||||
|
||||
@router.post("/sync-image")
|
||||
async def sync_product_image(
|
||||
product_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
site: Site = Depends(_require_site),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload/replace the cloud-hosted copy of a product's image. Called by
|
||||
local_backend during menu sync for products whose image changed."""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
contents = await file.read()
|
||||
|
||||
import hashlib
|
||||
image_hash = hashlib.sha256(contents).hexdigest()
|
||||
|
||||
record = (
|
||||
db.query(ProductImage)
|
||||
.filter(ProductImage.site_id == site.id, ProductImage.product_id == product_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
os.makedirs(PRODUCT_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
if record and record.image_hash == image_hash:
|
||||
return {"ok": True, "image_url": record.image_url, "unchanged": True}
|
||||
|
||||
if record:
|
||||
old_path = os.path.join(PRODUCT_IMAGE_DIR, os.path.basename(record.image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "jpg"
|
||||
filename = f"{site.id}_{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
filepath = os.path.join(PRODUCT_IMAGE_DIR, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
image_url = f"/static/product_images/{filename}"
|
||||
if record:
|
||||
record.image_url = image_url
|
||||
record.image_hash = image_hash
|
||||
else:
|
||||
record = ProductImage(site_id=site.id, product_id=product_id, image_url=image_url, image_hash=image_hash)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "image_url": image_url, "unchanged": False}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth_utils import get_current_admin
|
||||
@@ -12,6 +13,8 @@ from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRe
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
HEADER_IMAGE_DIR = "/app/data/site_headers"
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SiteOut])
|
||||
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
@@ -50,6 +53,8 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if body.menu_mode is not None and body.menu_mode not in ("order", "view_only"):
|
||||
raise HTTPException(status_code=400, detail="menu_mode must be 'order' or 'view_only'")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(site, field, value)
|
||||
db.commit()
|
||||
@@ -57,6 +62,51 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/header-image", response_model=SiteOut)
|
||||
async def upload_header_image(site_id: str, file: UploadFile = File(...), db: Session = Depends(get_db), _=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")
|
||||
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
os.makedirs(HEADER_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
if site.menu_header_image_url:
|
||||
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
filename = f"{site.site_id}_{uuid.uuid4().hex[:8]}.png"
|
||||
filepath = os.path.join(HEADER_IMAGE_DIR, filename)
|
||||
|
||||
contents = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
site.menu_header_image_url = f"/static/site_headers/{filename}"
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}/header-image", response_model=SiteOut)
|
||||
def delete_header_image(site_id: str, db: Session = Depends(get_db), _=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")
|
||||
|
||||
if site.menu_header_image_url:
|
||||
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
site.menu_header_image_url = None
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/lock", response_model=SiteOut)
|
||||
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
|
||||
@@ -8,7 +8,7 @@ class ManagerRegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
site_ids: list[int] = []
|
||||
site_ids: list[str] = [] # site_id UUID strings, not integer PKs
|
||||
|
||||
|
||||
class ManagerLoginRequest(BaseModel):
|
||||
|
||||
@@ -15,6 +15,13 @@ class SiteUpdate(BaseModel):
|
||||
contact_email: str | None = None
|
||||
license_expires_at: datetime | None = None
|
||||
waiter_domain: str | None = None
|
||||
menu_mode: str | None = None
|
||||
menu_display_name: str | None = None
|
||||
menu_tagline_en: str | None = None
|
||||
menu_tagline_gr: str | None = None
|
||||
menu_blurb_en: str | None = None
|
||||
menu_blurb_gr: str | None = None
|
||||
menu_hours: str | None = None
|
||||
|
||||
|
||||
class SiteOut(BaseModel):
|
||||
@@ -32,6 +39,14 @@ class SiteOut(BaseModel):
|
||||
last_seen_ip: str | None
|
||||
last_seen_local_ip: str | None
|
||||
waiter_domain: str | None
|
||||
menu_mode: str
|
||||
menu_display_name: str | None
|
||||
menu_tagline_en: str | None
|
||||
menu_tagline_gr: str | None
|
||||
menu_blurb_en: str | None
|
||||
menu_blurb_gr: str | None
|
||||
menu_hours: str | None
|
||||
menu_header_image_url: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -64,10 +64,20 @@ export default function DashboardPage() {
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-slate-400 text-right">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
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">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="el">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Xenia Menu</title>
|
||||
<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=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
|
||||
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
@@ -1,13 +1,11 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import MenuPage from './pages/MenuPage'
|
||||
import CartPage from './pages/CartPage'
|
||||
import OrderConfirm from './pages/OrderConfirm'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/:siteSlug" element={<MenuPage />} />
|
||||
<Route path="/:siteSlug/order" element={<CartPage />} />
|
||||
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const BASE = import.meta.env.VITE_CLOUD_URL || ''
|
||||
export const CLOUD_URL = import.meta.env.VITE_CLOUD_URL || ''
|
||||
|
||||
const api = axios.create({ baseURL: BASE })
|
||||
const api = axios.create({ baseURL: CLOUD_URL })
|
||||
|
||||
export async function fetchMenu(siteSlug) {
|
||||
const { data } = await api.get(`/api/menu/${siteSlug}`)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
|
||||
export default function CartButton({ cart, siteSlug }) {
|
||||
const navigate = useNavigate()
|
||||
const count = cart.reduce((s, i) => s + i.quantity, 0)
|
||||
const total = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
|
||||
if (!count) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-0 right-0 flex justify-center px-4 z-30">
|
||||
<button
|
||||
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl px-6 py-3.5 shadow-lg flex items-center gap-3 w-full max-w-sm transition-colors"
|
||||
>
|
||||
<span className="bg-emerald-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
|
||||
{count}
|
||||
</span>
|
||||
<span className="flex-1 font-semibold text-left">View order</span>
|
||||
<span className="font-bold">€{total.toFixed(2)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export default function CategoryNav({ categories, active, onSelect }) {
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1 scrollbar-hide">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => onSelect(cat.id)}
|
||||
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
active === cat.id
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export default function ProductCard({ product, onSelect }) {
|
||||
const unavailable = !product.digital_available
|
||||
const basePrice = product.digital_price ?? product.base_price
|
||||
const hasDiscount = product.digital_discount > 0 && !product.digital_price
|
||||
const displayPrice = hasDiscount
|
||||
? basePrice * (1 - product.digital_discount / 100)
|
||||
: basePrice
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
className={`w-full bg-white rounded-2xl shadow-sm p-4 flex gap-4 text-left transition-shadow ${
|
||||
unavailable ? 'opacity-60 cursor-default' : 'hover:shadow-md active:scale-[0.99]'
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
{(product.digital_image_url || product.image_url) && (
|
||||
<img
|
||||
src={product.digital_image_url || product.image_url}
|
||||
alt={product.digital_name || product.name}
|
||||
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-slate-800 leading-tight">
|
||||
{product.digital_name || product.name}
|
||||
</h3>
|
||||
{unavailable && (
|
||||
<span className="flex-shrink-0 text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full">
|
||||
Out of stock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.digital_description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-2">{product.digital_description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-baseline gap-2 pt-1">
|
||||
<span className="font-bold text-emerald-600">€{displayPrice.toFixed(2)}</span>
|
||||
{hasDiscount && (
|
||||
<>
|
||||
<span className="text-xs text-slate-400 line-through">€{basePrice.toFixed(2)}</span>
|
||||
<span className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded-full font-semibold">
|
||||
-{product.digital_discount}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,4 +3,16 @@
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
|
||||
import { submitOrder } from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function CartPage() {
|
||||
const { siteSlug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { state } = useLocation()
|
||||
const cart = state?.cart || []
|
||||
|
||||
const [orderType, setOrderType] = useState('dine_in')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const subtotal = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const deliveryFee = orderType === 'delivery' ? 2.0 : 0.0
|
||||
const total = subtotal + deliveryFee
|
||||
|
||||
if (!cart.length) {
|
||||
navigate(`/${siteSlug}`, { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) { toast.error('Please enter your name'); return }
|
||||
if (orderType === 'delivery' && !address.trim()) {
|
||||
toast.error('Please enter your delivery address')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await submitOrder(siteSlug, {
|
||||
order_type: orderType,
|
||||
customer_name: name.trim(),
|
||||
customer_phone: phone.trim() || null,
|
||||
customer_address: orderType === 'delivery' ? address.trim() : null,
|
||||
customer_notes: notes.trim() || null,
|
||||
items: cart.map(i => ({
|
||||
product_id: i.product_id,
|
||||
name: i.name,
|
||||
quantity: i.quantity,
|
||||
unit_price: i.unit_price,
|
||||
options: i.options || [],
|
||||
})),
|
||||
subtotal,
|
||||
delivery_fee: deliveryFee,
|
||||
total,
|
||||
})
|
||||
navigate(`/${siteSlug}/confirm/${result.public_ref}`, { replace: true })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Failed to place order. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-10">
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 shadow-sm">
|
||||
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-500 hover:text-slate-800">
|
||||
<ArrowLeft size={22} />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-slate-800">Your Order</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-lg mx-auto px-4 py-5 space-y-5">
|
||||
|
||||
{/* Order type */}
|
||||
<div className="bg-white rounded-2xl p-4 space-y-3 shadow-sm">
|
||||
<p className="text-sm font-semibold text-slate-700">Order type</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed },
|
||||
{ id: 'delivery', label: 'Delivery', Icon: Truck },
|
||||
].map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setOrderType(id)}
|
||||
className={`flex flex-col items-center gap-2 py-4 rounded-xl border-2 transition-colors ${
|
||||
orderType === id
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart summary */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
|
||||
{cart.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-sm">
|
||||
<span className="text-slate-700">{item.quantity}× {item.name}</span>
|
||||
<span className="text-slate-600 font-medium">€{(item.unit_price * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Subtotal</span><span>€{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{deliveryFee > 0 && (
|
||||
<div className="flex justify-between text-sm text-slate-500">
|
||||
<span>Delivery fee</span><span>€{deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-slate-800">
|
||||
<span>Total</span><span>€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer details */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">Your details</p>
|
||||
{[
|
||||
{ label: 'Name *', value: name, set: setName, type: 'text', placeholder: 'Full name' },
|
||||
{ label: 'Phone', value: phone, set: setPhone, type: 'tel', placeholder: 'Optional' },
|
||||
].map(({ label, value, set, type, placeholder }) => (
|
||||
<div key={label}>
|
||||
<label className="text-xs text-slate-500 font-medium">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => set(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{orderType === 'delivery' && (
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 font-medium">Delivery address *</label>
|
||||
<textarea
|
||||
value={address}
|
||||
onChange={e => setAddress(e.target.value)}
|
||||
placeholder="Street, number, city"
|
||||
rows={2}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 font-medium">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Allergies, special requests…"
|
||||
rows={2}
|
||||
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-4 rounded-xl font-bold text-base transition-colors"
|
||||
>
|
||||
{submitting ? 'Placing order…' : `Place order · €${total.toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } from 'lucide-react'
|
||||
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: 'text-amber-500', bg: 'bg-amber-50' },
|
||||
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
|
||||
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
|
||||
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
|
||||
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
||||
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'])
|
||||
@@ -42,48 +42,73 @@ export default function OrderConfirm() {
|
||||
return () => clearTimeout(timer)
|
||||
}, [ref])
|
||||
|
||||
const cfg = STATUS_CONFIG[orderStatus] || STATUS_CONFIG['pending_acceptance']
|
||||
const cfg = STATUS_CONFIG[orderStatus] ?? STATUS_CONFIG.pending_acceptance
|
||||
const { label, Icon, color, bg } = cfg
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
|
||||
<div className="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">
|
||||
|
||||
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
|
||||
<Icon className={color} size={40} />
|
||||
{/* 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>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
|
||||
<h1 className="text-2xl font-bold text-slate-800">
|
||||
{/* 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>
|
||||
{rejectionReason && (
|
||||
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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="text-xs text-slate-400">
|
||||
<p className="mt-3 text-[13px] text-[#9a917a]">
|
||||
We'll update this page automatically. Keep it open.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orderStatus === 'rejected' && (
|
||||
<p className="text-sm text-slate-500">
|
||||
We're sorry we couldn't take your order this time. Please try again or visit us in person.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orderStatus === 'delivered' && (
|
||||
<p className="text-sm text-slate-500">Thank you for your order! Enjoy your meal.</p>
|
||||
)}
|
||||
|
||||
{/* Spinner */}
|
||||
{!TERMINAL.has(orderStatus) && !error && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
|
||||
<div 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>
|
||||
)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Plus, Minus } from 'lucide-react'
|
||||
|
||||
export default function ProductModal({ product, onClose, onAdd }) {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [selectedOptions, setSelectedOptions] = useState([])
|
||||
|
||||
const basePrice = product.digital_price ?? product.base_price
|
||||
const hasDiscount = product.digital_discount > 0 && !product.digital_price
|
||||
const displayPrice = hasDiscount
|
||||
? basePrice * (1 - product.digital_discount / 100)
|
||||
: basePrice
|
||||
|
||||
function toggleOption(opt) {
|
||||
setSelectedOptions(prev =>
|
||||
prev.find(o => o.id === opt.id)
|
||||
? prev.filter(o => o.id !== opt.id)
|
||||
: [...prev, opt]
|
||||
)
|
||||
}
|
||||
|
||||
const optionsTotal = selectedOptions.reduce((s, o) => s + (o.price || 0), 0)
|
||||
const lineTotal = (displayPrice + optionsTotal) * quantity
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{/* Image */}
|
||||
{(product.digital_image_url || product.image_url) && (
|
||||
<img
|
||||
src={product.digital_image_url || product.image_url}
|
||||
alt={product.digital_name || product.name}
|
||||
className="w-full h-48 object-cover rounded-t-2xl sm:rounded-t-2xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{product.digital_name || product.name}
|
||||
</h2>
|
||||
{product.digital_description && (
|
||||
<p className="text-sm text-slate-500 mt-1">{product.digital_description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-emerald-600">
|
||||
€{displayPrice.toFixed(2)}
|
||||
</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-sm text-slate-400 line-through">
|
||||
€{basePrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{hasDiscount && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full font-semibold">
|
||||
-{product.digital_discount}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick options */}
|
||||
{product.quick_options?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-700">Options</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.quick_options.map(opt => {
|
||||
const active = selectedOptions.find(o => o.id === opt.id)
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => toggleOption(opt)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
||||
active
|
||||
? 'bg-emerald-500 text-white border-emerald-500'
|
||||
: 'bg-white text-slate-700 border-slate-200 hover:border-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{opt.name}{opt.price > 0 ? ` +€${opt.price.toFixed(2)}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-semibold text-slate-700">Quantity</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setQuantity(q => Math.max(1, q - 1))}
|
||||
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<span className="text-lg font-bold w-6 text-center">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(q => q + 1)}
|
||||
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to cart */}
|
||||
{product.digital_available ? (
|
||||
<button
|
||||
onClick={() => onAdd(product, quantity, selectedOptions)}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3.5 rounded-xl font-semibold flex items-center justify-between px-5 transition-colors"
|
||||
>
|
||||
<span>Add to order</span>
|
||||
<span>€{lineTotal.toFixed(2)}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full bg-slate-100 text-slate-400 py-3.5 rounded-xl font-semibold text-center">
|
||||
Currently unavailable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,49 @@ export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
|
||||
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: [],
|
||||
|
||||
10
sysadmin_panel/package-lock.json
generated
10
sysadmin_panel/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
@@ -2472,6 +2473,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
|
||||
@@ -22,3 +22,16 @@ client.interceptors.response.use(
|
||||
)
|
||||
|
||||
export default client
|
||||
|
||||
// ── Remote Manager accounts ───────────────────────────────────────────────────
|
||||
|
||||
export const getManagersBySite = (siteId) =>
|
||||
client.get(`/api/manager/by-site/${siteId}`)
|
||||
|
||||
export const addManagerToSite = (email, fullName, password, siteId) =>
|
||||
client.post('/api/manager/register', {
|
||||
email, full_name: fullName, password, site_ids: [siteId],
|
||||
})
|
||||
|
||||
export const removeManagerSiteAccess = (managerId, siteId) =>
|
||||
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import { QRCodeCanvas } from 'qrcode.react'
|
||||
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
|
||||
import LicenseStatus from '../components/LicenseStatus'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
@@ -26,12 +27,32 @@ export default function SiteDetailPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
|
||||
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager' | 'menu_settings'
|
||||
const [lockReason, setLockReason] = useState('')
|
||||
const [newExpiry, setNewExpiry] = useState('')
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
// Menu settings form state
|
||||
const [menuMode, setMenuMode] = useState('order')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [taglineEn, setTaglineEn] = useState('')
|
||||
const [taglineGr, setTaglineGr] = useState('')
|
||||
const [blurbEn, setBlurbEn] = useState('')
|
||||
const [blurbGr, setBlurbGr] = useState('')
|
||||
const [hours, setHours] = useState('')
|
||||
const [headerImageFile, setHeaderImageFile] = useState(null)
|
||||
const [uploadingHeaderImage, setUploadingHeaderImage] = useState(false)
|
||||
|
||||
// Remote Managers state
|
||||
const [managers, setManagers] = useState([])
|
||||
const [managersLoading, setManagersLoading] = useState(false)
|
||||
const [newMgrEmail, setNewMgrEmail] = useState('')
|
||||
const [newMgrName, setNewMgrName] = useState('')
|
||||
const [newMgrPass, setNewMgrPass] = useState('')
|
||||
const [addingMgr, setAddingMgr] = useState(false)
|
||||
const [removingMgrId, setRemovingMgrId] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
@@ -47,6 +68,49 @@ export default function SiteDetailPage() {
|
||||
load()
|
||||
}, [siteId])
|
||||
|
||||
const fetchManagers = useCallback(async () => {
|
||||
setManagersLoading(true)
|
||||
try {
|
||||
const { data } = await getManagersBySite(siteId)
|
||||
setManagers(data)
|
||||
} catch {
|
||||
toast.error('Failed to load managers')
|
||||
} finally {
|
||||
setManagersLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => { fetchManagers() }, [fetchManagers])
|
||||
|
||||
async function doAddManager() {
|
||||
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
|
||||
setAddingMgr(true)
|
||||
try {
|
||||
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), siteId)
|
||||
toast.success('Manager added')
|
||||
setModal(null)
|
||||
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
|
||||
fetchManagers()
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to add manager')
|
||||
} finally {
|
||||
setAddingMgr(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveManager(managerId) {
|
||||
setRemovingMgrId(managerId)
|
||||
try {
|
||||
await removeManagerSiteAccess(managerId, siteId)
|
||||
toast.success('Access removed')
|
||||
setManagers(prev => prev.filter(m => m.id !== managerId))
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to remove access')
|
||||
} finally {
|
||||
setRemovingMgrId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function doLock() {
|
||||
if (!lockReason.trim()) return
|
||||
setActing(true)
|
||||
@@ -110,6 +174,58 @@ export default function SiteDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function doSaveMenuSettings() {
|
||||
setActing(true)
|
||||
try {
|
||||
const { data } = await client.put(`/api/sites/${siteId}`, {
|
||||
menu_mode: menuMode,
|
||||
menu_display_name: displayName.trim(),
|
||||
menu_tagline_en: taglineEn.trim(),
|
||||
menu_tagline_gr: taglineGr.trim(),
|
||||
menu_blurb_en: blurbEn.trim(),
|
||||
menu_blurb_gr: blurbGr.trim(),
|
||||
menu_hours: hours.trim(),
|
||||
})
|
||||
setSite(data)
|
||||
setModal(null)
|
||||
toast.success('Menu settings updated')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to update menu settings')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doUploadHeaderImage() {
|
||||
if (!headerImageFile) return
|
||||
setUploadingHeaderImage(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', headerImageFile)
|
||||
const { data } = await client.post(`/api/sites/${siteId}/header-image`, form)
|
||||
setSite(data)
|
||||
setHeaderImageFile(null)
|
||||
toast.success('Header image uploaded')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to upload header image')
|
||||
} finally {
|
||||
setUploadingHeaderImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveHeaderImage() {
|
||||
setUploadingHeaderImage(true)
|
||||
try {
|
||||
const { data } = await client.delete(`/api/sites/${siteId}/header-image`)
|
||||
setSite(data)
|
||||
toast.success('Header image removed')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to remove header image')
|
||||
} finally {
|
||||
setUploadingHeaderImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
setActing(true)
|
||||
try {
|
||||
@@ -228,6 +344,78 @@ export default function SiteDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Settings */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">QR Menu Settings</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuMode(site.menu_mode || 'order')
|
||||
setDisplayName(site.menu_display_name || '')
|
||||
setTaglineEn(site.menu_tagline_en || '')
|
||||
setTaglineGr(site.menu_tagline_gr || '')
|
||||
setBlurbEn(site.menu_blurb_en || '')
|
||||
setBlurbGr(site.menu_blurb_gr || '')
|
||||
setHours(site.menu_hours || '')
|
||||
setModal('menu_settings')
|
||||
}}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Edit →
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Mode</span>
|
||||
<span className={`font-medium ${site.menu_mode === 'view_only' ? 'text-yellow-400' : 'text-emerald-400'}`}>
|
||||
{site.menu_mode === 'view_only' ? 'View Menu Only' : 'Ordering Enabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Display name</span>
|
||||
<span className="text-gray-300">{site.menu_display_name || <span className="text-gray-600 italic">Not set</span>}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Header image</span>
|
||||
<span className="text-gray-300">{site.menu_header_image_url ? 'Set' : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{site.menu_header_image_url && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_CLOUD_URL || 'http://localhost:8001'}${site.menu_header_image_url}`}
|
||||
alt="Menu header"
|
||||
className="max-h-16 max-w-[60%] rounded-lg bg-gray-800 object-contain p-1.5"
|
||||
/>
|
||||
<button
|
||||
onClick={doRemoveHeaderImage}
|
||||
disabled={uploadingHeaderImage}
|
||||
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png"
|
||||
onChange={e => setHeaderImageFile(e.target.files?.[0] || null)}
|
||||
className="flex-1 text-xs text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-gray-800 file:px-3 file:py-1.5 file:text-xs file:text-gray-300 hover:file:bg-gray-700"
|
||||
/>
|
||||
<button
|
||||
onClick={doUploadHeaderImage}
|
||||
disabled={!headerImageFile || uploadingHeaderImage}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 disabled:hover:bg-cyan-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{uploadingHeaderImage ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1.5">PNG only. Replaces the restaurant name on the menu header (shown at up to 80% width).</p>
|
||||
</div>
|
||||
|
||||
{/* Lock status */}
|
||||
{site.is_locked && (
|
||||
<div className="bg-red-900/20 border border-red-800/50 rounded-xl p-4 mb-4">
|
||||
@@ -270,6 +458,99 @@ export default function SiteDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Codes */}
|
||||
{(() => {
|
||||
const slug = site.site_id
|
||||
const menuUrl = `http://72.61.191.197:3100/menu/${slug}`
|
||||
const orderUrl = `http://72.61.191.197:3100/menu/${slug}/order`
|
||||
|
||||
function handleDownload(canvasId, filename) {
|
||||
const canvas = document.getElementById(canvasId)
|
||||
if (!canvas) return
|
||||
const url = canvas.toDataURL('image/png')
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">QR Codes</h2>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{[
|
||||
{ id: `qr-menu-${site.id}`, url: menuUrl, label: 'Menu QR Code', file: `menu-qr-${slug}.png` },
|
||||
{ id: `qr-order-${site.id}`, url: orderUrl, label: 'Order QR Code', file: `order-qr-${slug}.png` },
|
||||
].map(({ id, url, label, file }) => (
|
||||
<div key={id} className="flex flex-col items-center gap-3">
|
||||
<p className="text-xs font-medium text-gray-400">{label}</p>
|
||||
<div className="bg-white p-3 rounded-xl">
|
||||
<QRCodeCanvas id={id} value={url} size={180} includeMargin={false} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 font-mono break-all max-w-[220px] text-center">{url}</p>
|
||||
<button
|
||||
onClick={() => handleDownload(id, file)}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Remote Managers */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
|
||||
<button
|
||||
onClick={() => setModal('add_manager')}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
+ Add Manager
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{managersLoading && (
|
||||
<p className="text-xs text-gray-500">Loading…</p>
|
||||
)}
|
||||
|
||||
{!managersLoading && managers.length === 0 && (
|
||||
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
|
||||
)}
|
||||
|
||||
{!managersLoading && managers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{managers.map(mgr => (
|
||||
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
|
||||
{mgr.full_name && (
|
||||
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
|
||||
}`}>
|
||||
{mgr.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => doRemoveManager(mgr.id)}
|
||||
disabled={removingMgrId === mgr.id}
|
||||
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{removingMgrId === mgr.id ? '…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{modal === 'lock' && (
|
||||
<ConfirmModal
|
||||
@@ -349,6 +630,157 @@ export default function SiteDetailPage() {
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
)}
|
||||
|
||||
{modal === 'menu_settings' && (
|
||||
<ConfirmModal
|
||||
title="QR Menu Settings"
|
||||
confirmLabel={acting ? 'Saving…' : 'Save'}
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={doSaveMenuSettings}
|
||||
>
|
||||
<div className="space-y-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Mode</label>
|
||||
<div className="flex rounded-lg bg-gray-800 p-1 ring-1 ring-gray-600">
|
||||
{[
|
||||
{ value: 'order', label: 'Order' },
|
||||
{ value: 'view_only', label: 'View Menu Only' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setMenuMode(opt.value)}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
menuMode === opt.value
|
||||
? 'bg-cyan-700 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
"View Menu Only" hides all cart/ordering controls on the public QR menu — customers can browse but not order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Display name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="Olive & Thyme"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Shown on the public menu header. Separate from the internal site name above — leave empty to show a generic "Our Menu" title (or hide it entirely once a header image is set).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Tagline (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taglineEn}
|
||||
onChange={e => setTaglineEn(e.target.value)}
|
||||
placeholder="Kitchen & Bar"
|
||||
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">Tagline (GR)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taglineGr}
|
||||
onChange={e => setTaglineGr(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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Blurb (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={blurbEn}
|
||||
onChange={e => setBlurbEn(e.target.value)}
|
||||
placeholder="Fresh seasonal plates, served with care."
|
||||
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">Blurb (GR)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={blurbGr}
|
||||
onChange={e => setBlurbGr(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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Hours</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hours}
|
||||
onChange={e => setHours(e.target.value)}
|
||||
placeholder="Open today · 12:00 – 23:30"
|
||||
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>
|
||||
<p className="text-xs text-gray-500">Leave a field empty to hide it from the menu header.</p>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
)}
|
||||
|
||||
{modal === 'add_manager' && (
|
||||
<ConfirmModal
|
||||
title="Add Remote Manager"
|
||||
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
|
||||
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
|
||||
onConfirm={doAddManager}
|
||||
>
|
||||
<div className="space-y-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newMgrEmail}
|
||||
onChange={e => setNewMgrEmail(e.target.value)}
|
||||
placeholder="manager@example.com"
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newMgrName}
|
||||
onChange={e => setNewMgrName(e.target.value)}
|
||||
placeholder="Jane Smith"
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={newMgrPass}
|
||||
onChange={e => setNewMgrPass(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user