Compare commits

...

5 Commits

Author SHA1 Message Date
7a53cc3d66 feat: receive and serve product images pushed from local sites
New POST /api/menu/sync-image (site-authenticated) accepts a product's
image file, stores it under /app/data/product_images keyed by
(site, product_id), and skips the write entirely if the uploaded
content hash matches what's already stored. GET /api/menu/{site_slug}
now injects the cloud-hosted image URL into each product that has no
manual digital_image_url override — replacing the local-only image_url
from the snapshot, which was never reachable from the public internet.

Also fixes the menu-app resolving product image URLs as bare relative
paths instead of prefixing them with the cloud API origin (same class
of bug as the earlier header-image fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:57:00 +03:00
6c7df8d011 fix(menu-app): replace EN|ΕΛ pill with a compact globe icon + dropdown
The two-segment language pill sat over the header image/logo area and
clipped it. Swapped for a small globe icon button tucked closer to the
corner, opening a lightweight dropdown with full language names on tap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:16:18 +03:00
a73f081ca1 fix: QR menu branding fields — display name, header image, empty-to-hide
- menu_display_name is now separate from the site's internal name (was
  incorrectly showing the sysadmin-only site name on the public menu).
- Header image now resolves against the cloud API origin instead of
  the menu-app's own origin, fixing the broken-image icon.
- Tagline/blurb/hours now genuinely hide when cleared: the sysadmin
  save handler was converting empty fields to null, which the PUT
  endpoint's exclude_none silently drops instead of clearing.
- Added a settable blurb (EN/GR) below the tagline, previously
  hardcoded ("Fresh seasonal plates, served with care.").
- Collapsed hours from two fields (EN/GR) to one — opening hours don't
  vary by language.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:44:28 +03:00
f5736b85cb fix(menu-app): crash when tagline/hours fields are null
The API now always sends restaurant.tagline/blurb as {en, gr} objects,
including when a site hasn't set them yet (both null). The old
`?? r.tagline` fallback rendered the whole object as a React child in
that case (React error #31) instead of falling back to a display
string. Fall back to the localized fallback string instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:22:43 +03:00
d87540e08f 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>
2026-07-19 09:59:35 +03:00
9 changed files with 574 additions and 81 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
@@ -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():

View 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"),)

View File

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

View File

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

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

View File

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

View File

@@ -1,25 +1,19 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
MapPin, Search, Leaf, X, Plus, ShoppingBag,
Search, Leaf, X, Plus, ShoppingBag,
ChevronLeft, ArrowRight, Send, Loader2, Info,
Armchair, Check, SearchX, UtensilsCrossed, AlertCircle,
Soup, Salad, Wheat, IceCream2, Wine,
Soup, Salad, Wheat, IceCream2, Wine, Globe,
} from 'lucide-react'
import { fetchMenu, submitOrder } from '../api'
import { fetchMenu, submitOrder, CLOUD_URL } from '../api'
import {
DishArt, Badge, DietChip, TagIcons, Price, DiscountFlag, Stepper,
eur, discountedPrice, discountPct,
} from '../components/primitives'
// TODO: Replace with real data from API once backend includes restaurant info
const RESTAURANT_FALLBACK = {
name: 'Our Menu',
tagline: { en: 'Kitchen & Bar', gr: 'Κουζίνα & Μπαρ' },
blurb: { en: 'Fresh seasonal plates, served with care.', gr: 'Εποχιακά πιάτα, με αγάπη.' },
hours: { en: 'Open today · 12:00 23:30', gr: 'Ανοιχτά σήμερα · 12:00 23:30' },
location: { en: '', gr: '' },
}
// Shown only when a site has never been configured at all (brand-new site, restaurant === null)
const RESTAURANT_FALLBACK = { name: 'Our Menu' }
// Category glyph icons — mapped by category id or index
const GLYPH_BY_ID = { starters: Soup, salads: Salad, mains: UtensilsCrossed, sides: Wheat, desserts: IceCream2, drinks: Wine }
@@ -60,6 +54,13 @@ const I18N = {
},
}
// Resolve a possibly-relative path (e.g. "/static/product_images/x.jpg") against
// the cloud API origin. Absolute URLs (manager-set external overrides) pass through untouched.
function resolveImageUrl(url) {
if (!url) return null
return /^https?:\/\//i.test(url) ? url : `${CLOUD_URL}${url}`
}
// ── Normalise a backend product to the shape the UI expects ──────────────────
function normaliseProduct(p, catId) {
return {
@@ -74,7 +75,7 @@ function normaliseProduct(p, catId) {
allergens: p.allergens || [],
ingredients: p.ingredients || null,
discountPct: p.digital_discount || p.discountPct || 0,
image_url: p.digital_image_url || p.image_url || null,
image_url: resolveImageUrl(p.digital_image_url || p.image_url || null),
digital_available: p.digital_available !== false,
}
}
@@ -119,42 +120,82 @@ function SheetHandle() {
)
}
// ── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ lang, setLang, restaurant }) {
const r = { ...RESTAURANT_FALLBACK, ...restaurant }
// ── Language Switcher ────────────────────────────────────────────────────────
const LANGS = [
{ code: 'en', label: 'English' },
{ code: 'gr', label: 'Ελληνικά' },
]
function LanguageSwitcher({ lang, setLang }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
if (!open) return
const onClickAway = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', onClickAway)
return () => document.removeEventListener('mousedown', onClickAway)
}, [open])
return (
<header className="relative px-6 pt-7 pb-6 text-center">
{/* Language toggle */}
<div className="absolute right-5 top-6">
<div className="flex items-center rounded-full bg-white/70 p-0.5 text-[11px] font-semibold ring-1 ring-[#e3dcc9] backdrop-blur">
{['en', 'gr'].map(l => (
<div ref={ref} className="absolute right-3 top-3 z-20">
<button
key={l}
onClick={() => setLang(l)}
className={`rounded-full px-2.5 py-1 uppercase tracking-wider transition ${
lang === l ? 'bg-[#2d3b2d] text-[#f0e9d6]' : 'text-[#8a8266]'
onClick={() => setOpen(o => !o)}
aria-label="Change language"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-[#6d6a59] ring-1 ring-[#e3dcc9] backdrop-blur transition active:scale-95"
>
<Globe className="h-4 w-4" strokeWidth={1.8} />
</button>
{open && (
<div className="absolute right-0 top-10 min-w-[140px] overflow-hidden rounded-2xl bg-[#fcfbf7] py-1.5 shadow-card ring-1 ring-[#e7e1d1]">
{LANGS.map(({ code, label }) => (
<button
key={code}
onClick={() => { setLang(code); setOpen(false) }}
className={`flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-[13px] font-medium transition ${
lang === code ? 'text-[#2d3b2d]' : 'text-[#8a8266] hover:text-[#2d3b2d]'
}`}
>
{l === 'en' ? 'EN' : 'ΕΛ'}
{label}
{lang === code && <Check className="h-3.5 w-3.5" strokeWidth={2.4} />}
</button>
))}
</div>
)}
</div>
)
}
{r.location?.[lang] && (
<div className="mx-auto inline-flex items-center gap-1.5 rounded-full bg-white/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#8a7f5e] ring-1 ring-[#e8e1d1]">
<MapPin className="h-3 w-3" strokeWidth={2} />
{r.location[lang]}
// ── Hero ──────────────────────────────────────────────────────────────────────
function Hero({ lang, setLang, restaurant }) {
const name = restaurant?.name || RESTAURANT_FALLBACK.name
const tagline = restaurant?.tagline?.[lang]
const blurb = restaurant?.blurb?.[lang]
const hours = restaurant?.hours
const headerImageUrl = restaurant?.headerImageUrl ? `${CLOUD_URL}${restaurant.headerImageUrl}` : null
return (
<header className="relative px-6 pt-7 pb-6 text-center">
<LanguageSwitcher lang={lang} setLang={setLang} />
{headerImageUrl ? (
<img
src={headerImageUrl}
alt={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]">
{name}
</h1>
)}
{tagline && (
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
{tagline}
</div>
)}
<h1 className="mt-4 font-display text-[42px] font-semibold leading-[0.95] tracking-tight text-[#2d3b2d]">
{r.name}
</h1>
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
{r.tagline?.[lang] ?? r.tagline ?? ''}
</div>
{/* Ornament */}
<div className="mx-auto my-4 flex w-40 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
@@ -162,17 +203,19 @@ function Hero({ lang, setLang, restaurant }) {
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
{blurb && (
<p className="mx-auto max-w-[300px] text-[13px] leading-relaxed text-[#7d7660]">
{r.blurb?.[lang] ?? r.blurb ?? ''}
{blurb}
</p>
)}
{r.hours?.[lang] && (
{hours && (
<div className="mt-2.5 inline-flex items-center gap-1.5 text-[12px] font-medium text-[#3f7d4e]">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#3f7d4e] opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[#3f7d4e]" />
</span>
{r.hours[lang]}
{hours}
</div>
)}
</header>
@@ -236,7 +279,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 +319,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 +328,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 +337,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 +361,7 @@ function Section({ category, lang, t, onOpen, onAdd, cart, sectionRef }) {
onOpen={onOpen}
onAdd={onAdd}
qty={cart[p.id] || 0}
viewOnly={viewOnly}
/>
))}
</div>
@@ -325,7 +371,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 +455,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 +463,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 +471,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 +546,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 +798,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 +825,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 +919,7 @@ export default function MenuPage() {
onAdd={addToCart}
cart={cart}
sectionRef={el => { sectionRefs.current[cat.id] = el }}
viewOnly={viewOnly}
/>
))}
@@ -880,7 +932,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 +944,7 @@ export default function MenuPage() {
qty={activeProduct ? (cart[activeProduct.id] || 0) : 0}
onInc={incCart}
onDec={decCart}
viewOnly={viewOnly}
/>
<SearchOverlay
@@ -903,8 +956,10 @@ export default function MenuPage() {
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
viewOnly={viewOnly}
/>
{!viewOnly && (
<CartFlow
stage={stage}
setStage={setStage}
@@ -917,6 +972,7 @@ export default function MenuPage() {
siteSlug={siteSlug}
navigate={navigate}
/>
)}
</div>
)
}

View File

@@ -27,12 +27,23 @@ 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 [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)
@@ -163,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 {
@@ -281,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">
@@ -496,6 +631,115 @@ 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>
<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"