feat: per-site QR menu mode (order vs view-only) + editable branding

Adds a sysadmin-configurable toggle so sites can disable online ordering
on the public QR menu until it's fully supported, plus editable
tagline/hours and a header image (replacing the hardcoded "Our Menu"
placeholder) — all previously hardcoded frontend strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:59:35 +03:00
parent 0cad6a76d3
commit d87540e08f
7 changed files with 358 additions and 39 deletions

View File

@@ -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():

View File

@@ -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)

View File

@@ -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) ───────────────────────────────────────────────────

View File

@@ -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()

View File

@@ -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}

View File

@@ -148,9 +148,17 @@ function Hero({ lang, setLang, restaurant }) {
</div>
)}
{r.headerImageUrl ? (
<img
src={r.headerImageUrl}
alt={r.name}
className="mx-auto mt-4 block max-w-[80%] max-h-[120px] w-auto object-contain"
/>
) : (
<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>
@@ -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,6 +284,7 @@ function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
{!viewOnly && (
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
@@ -284,6 +293,7 @@ function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
<Plus className="h-4 w-4" strokeWidth={2.4} />
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
</button>
)}
</div>
</div>
</div>
@@ -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}
/>
))}
</div>
@@ -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 */}
<div className="flex items-center gap-3 border-t border-[#ece5d5] bg-[#faf7f0] px-5 py-3.5">
{qty > 0 ? (
{!viewOnly && qty > 0 ? (
<Stepper qty={qty} onInc={() => onInc(product)} onDec={() => onDec(product)} />
) : (
<div className="flex items-baseline gap-2">
@@ -417,6 +428,7 @@ function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc,
<DiscountFlag product={product} t={t} />
</div>
)}
{!viewOnly && (
<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]"
@@ -424,13 +436,14 @@ function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc,
<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 }) {
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() {
</footer>
</main>
<CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />
{!viewOnly && <CartButton count={count} total={total} t={t} onClick={() => setStage('cart')} />}
<ProductSheet
product={activeProduct}
@@ -892,6 +909,7 @@ export default function MenuPage() {
qty={activeProduct ? (cart[activeProduct.id] || 0) : 0}
onInc={incCart}
onDec={decCart}
viewOnly={viewOnly}
/>
<SearchOverlay
@@ -903,8 +921,10 @@ export default function MenuPage() {
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
viewOnly={viewOnly}
/>
{!viewOnly && (
<CartFlow
stage={stage}
setStage={setStage}
@@ -917,6 +937,7 @@ export default function MenuPage() {
siteSlug={siteSlug}
navigate={navigate}
/>
)}
</div>
)
}

View File

@@ -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() {
</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')
setTaglineEn(site.menu_tagline_en || '')
setTaglineGr(site.menu_tagline_gr || '')
setHoursEn(site.menu_hours_en || '')
setHoursGr(site.menu_hours_gr || '')
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">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">
@@ -496,6 +621,90 @@ export default function SiteDetailPage() {
</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 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">Hours (EN)</label>
<input
type="text"
value={hoursEn}
onChange={e => setHoursEn(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>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Hours (GR)</label>
<input
type="text"
value={hoursGr}
onChange={e => setHoursGr(e.target.value)}
placeholder="Ανοιχτά σήμερα · 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>
</div>
<p className="text-xs text-gray-500">Leave a field empty to clear it from the menu header.</p>
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"