diff --git a/cloud_backend/main.py b/cloud_backend/main.py
index 9b9b501..8cf5b12 100644
--- a/cloud_backend/main.py
+++ b/cloud_backend/main.py
@@ -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
@@ -39,6 +41,13 @@ 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",
+ "ALTER TABLE sites ADD COLUMN menu_hours_gr VARCHAR",
+ "ALTER TABLE sites ADD COLUMN menu_header_image_url VARCHAR",
]
for sql in migrations:
try:
@@ -74,6 +83,9 @@ 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")
+
@app.get("/health")
def health():
diff --git a/cloud_backend/models/site.py b/cloud_backend/models/site.py
index 5e6b786..8459be0 100644
--- a/cloud_backend/models/site.py
+++ b/cloud_backend/models/site.py
@@ -23,3 +23,11 @@ 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_tagline_en = Column(String, nullable=True)
+ menu_tagline_gr = Column(String, nullable=True)
+ menu_hours_en = Column(String, nullable=True)
+ menu_hours_gr = Column(String, nullable=True)
+ menu_header_image_url = Column(String, nullable=True)
diff --git a/cloud_backend/routers/menu.py b/cloud_backend/routers/menu.py
index 57b5032..1ca5340 100644
--- a/cloud_backend/routers/menu.py
+++ b/cloud_backend/routers/menu.py
@@ -36,7 +36,15 @@ 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.name,
+ "tagline": {"en": site.menu_tagline_en, "gr": site.menu_tagline_gr},
+ "hours": {"en": site.menu_hours_en, "gr": site.menu_hours_gr},
+ "headerImageUrl": site.menu_header_image_url,
+ }
+ return data
# ── Internal (site API key) ───────────────────────────────────────────────────
diff --git a/cloud_backend/routers/sites.py b/cloud_backend/routers/sites.py
index 7c1b0a4..6318355 100644
--- a/cloud_backend/routers/sites.py
+++ b/cloud_backend/routers/sites.py
@@ -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()
diff --git a/cloud_backend/schemas/site.py b/cloud_backend/schemas/site.py
index 0ede54c..edfab04 100644
--- a/cloud_backend/schemas/site.py
+++ b/cloud_backend/schemas/site.py
@@ -15,6 +15,11 @@ 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_tagline_en: str | None = None
+ menu_tagline_gr: str | None = None
+ menu_hours_en: str | None = None
+ menu_hours_gr: str | None = None
class SiteOut(BaseModel):
@@ -32,6 +37,12 @@ class SiteOut(BaseModel):
last_seen_ip: str | None
last_seen_local_ip: str | None
waiter_domain: str | None
+ menu_mode: str
+ menu_tagline_en: str | None
+ menu_tagline_gr: str | None
+ menu_hours_en: str | None
+ menu_hours_gr: str | None
+ menu_header_image_url: str | None
model_config = {"from_attributes": True}
diff --git a/connect_frontend/menu-app/src/pages/MenuPage.jsx b/connect_frontend/menu-app/src/pages/MenuPage.jsx
index 7f33ba0..60cc1a3 100644
--- a/connect_frontend/menu-app/src/pages/MenuPage.jsx
+++ b/connect_frontend/menu-app/src/pages/MenuPage.jsx
@@ -148,9 +148,17 @@ function Hero({ lang, setLang, restaurant }) {
)}
-
- {r.name}
-
+ {r.headerImageUrl ? (
+
+ ) : (
+
+ {r.name}
+
+ )}
{r.tagline?.[lang] ?? r.tagline ?? ''}
@@ -236,7 +244,7 @@ function CategoryBar({ categories, active, onPick, onSearch, lang }) {
}
// ── Product Card ──────────────────────────────────────────────────────────────
-function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
+function ProductCard({ product, category, lang, t, onOpen, onAdd, qty, viewOnly }) {
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
@@ -276,14 +284,16 @@ function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
-
+ {!viewOnly && (
+
+ )}
@@ -292,7 +302,7 @@ function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
}
// ── Menu Section ──────────────────────────────────────────────────────────────
-function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
+function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef, viewOnly }) {
const { hue, products } = category
const label = typeof category.name === 'object' ? (category.name[lang] ?? category.name.en) : category.name
return (
@@ -316,6 +326,7 @@ function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
onOpen={onOpen}
onAdd={onAdd}
qty={cart[p.id] || 0}
+ viewOnly={viewOnly}
/>
))}
@@ -325,7 +336,7 @@ function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
}
// ── Product Detail Sheet ──────────────────────────────────────────────────────
-function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec }) {
+function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc, onDec, viewOnly }) {
if (!product) return null
const hue = category?.hue ?? 40
const GlyphIcon = category?.GlyphIcon ?? UtensilsCrossed
@@ -409,7 +420,7 @@ function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc,
{/* Sticky add bar */}
- {qty > 0 ? (
+ {!viewOnly && qty > 0 ? (
onInc(product)} onDec={() => onDec(product)} />
) : (
@@ -417,20 +428,22 @@ function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc,
)}
-
+ {!viewOnly && (
+
+ )}
)
}
// ── Search Overlay ────────────────────────────────────────────────────────────
-function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart }) {
+function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart, viewOnly }) {
const [q, setQ] = useState('')
const inputRef = useRef(null)
useEffect(() => { if (open && inputRef.current) inputRef.current.focus() }, [open])
@@ -498,6 +511,7 @@ function SearchOverlay({ open, onClose, categories, lang, t, onOpen, onAdd, cart
onOpen={prod => { onClose(); onOpen(prod) }}
onAdd={onAdd}
qty={cart[p.id] || 0}
+ viewOnly={viewOnly}
/>
)
})}
@@ -749,6 +763,7 @@ export default function MenuPage() {
const [categories, setCategories] = useState([])
const [restaurant, setRestaurant] = useState(null)
+ const [viewOnly, setViewOnly] = useState(false)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
@@ -775,6 +790,7 @@ export default function MenuPage() {
const cats = normaliseCategories(data.categories || [])
setCategories(cats)
setRestaurant(data.restaurant ?? null)
+ setViewOnly(data.menu_mode === 'view_only')
if (cats.length) setActive(cats[0].id)
})
.catch(() => setError('Menu not available. Please try again.'))
@@ -868,6 +884,7 @@ export default function MenuPage() {
onAdd={addToCart}
cart={cart}
sectionRef={el => { sectionRefs.current[cat.id] = el }}
+ viewOnly={viewOnly}
/>
))}
@@ -880,7 +897,7 @@ export default function MenuPage() {
- setStage('cart')} />
+ {!viewOnly && setStage('cart')} />}
-
+ {!viewOnly && (
+
+ )}
)
}
diff --git a/sysadmin_panel/src/pages/SiteDetailPage.jsx b/sysadmin_panel/src/pages/SiteDetailPage.jsx
index f518a28..9d2d3c3 100644
--- a/sysadmin_panel/src/pages/SiteDetailPage.jsx
+++ b/sysadmin_panel/src/pages/SiteDetailPage.jsx
@@ -27,12 +27,21 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
- const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
+ 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 [taglineEn, setTaglineEn] = useState('')
+ const [taglineGr, setTaglineGr] = useState('')
+ const [hoursEn, setHoursEn] = useState('')
+ const [hoursGr, setHoursGr] = useState('')
+ const [headerImageFile, setHeaderImageFile] = useState(null)
+ const [uploadingHeaderImage, setUploadingHeaderImage] = useState(false)
+
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
@@ -163,6 +172,56 @@ export default function SiteDetailPage() {
}
}
+ async function doSaveMenuSettings() {
+ setActing(true)
+ try {
+ const { data } = await client.put(`/api/sites/${siteId}`, {
+ menu_mode: menuMode,
+ menu_tagline_en: taglineEn.trim() || null,
+ menu_tagline_gr: taglineGr.trim() || null,
+ menu_hours_en: hoursEn.trim() || null,
+ menu_hours_gr: hoursGr.trim() || null,
+ })
+ 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 {
@@ -281,6 +340,72 @@ export default function SiteDetailPage() {
+ {/* Menu Settings */}
+
+
+
QR Menu Settings
+
+
+
+
+ Mode
+
+ {site.menu_mode === 'view_only' ? 'View Menu Only' : 'Ordering Enabled'}
+
+
+
+ Header image
+ {site.menu_header_image_url ? 'Set' : '—'}
+
+
+
+ {site.menu_header_image_url && (
+
+

+
+
+ )}
+
+
+ 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"
+ />
+
+
+
PNG only. Replaces the restaurant name on the menu header (shown at up to 80% width).
+
+
{/* Lock status */}
{site.is_locked && (
@@ -496,6 +621,90 @@ export default function SiteDetailPage() {
)}
+ {modal === 'menu_settings' && (
+
setModal(null)}
+ onConfirm={doSaveMenuSettings}
+ >
+
+
+
+
+ {[
+ { value: 'order', label: 'Order' },
+ { value: 'view_only', label: 'View Menu Only' },
+ ].map(opt => (
+
+ ))}
+
+
+ "View Menu Only" hides all cart/ordering controls on the public QR menu — customers can browse but not order.
+
+
+
+
+
+
+
Leave a field empty to clear it from the menu header.
+
+
+ )}
+
{modal === 'add_manager' && (