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

@@ -148,9 +148,17 @@ function Hero({ lang, setLang, restaurant }) {
</div>
)}
<h1 className="mt-4 font-display text-[42px] font-semibold leading-[0.95] tracking-tight text-[#2d3b2d]">
{r.name}
</h1>
{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,14 +284,16 @@ function ProductCard({ product, category, lang, t, onOpen, onAdd, qty }) {
<Price product={product} large />
<DiscountFlag product={product} t={t} />
</div>
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
className="flex h-8 items-center gap-1 rounded-full bg-[#2d3b2d] pl-2.5 pr-3 text-[12px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-95 hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
</button>
{!viewOnly && (
<button
onClick={e => { e.stopPropagation(); if (!unavailable) onAdd(product) }}
aria-label={t.add}
className="flex h-8 items-center gap-1 rounded-full bg-[#2d3b2d] pl-2.5 pr-3 text-[12px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-95 hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{qty > 0 ? <span className="tabular-nums">{qty}</span> : t.add}
</button>
)}
</div>
</div>
</div>
@@ -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,20 +428,22 @@ function ProductSheet({ product, category, lang, t, onClose, onAdd, qty, onInc,
<DiscountFlag product={product} t={t} />
</div>
)}
<button
onClick={() => { onAdd(product); onClose() }}
className="ml-auto flex h-11 flex-1 items-center justify-center gap-2 rounded-full bg-[#2d3b2d] px-5 text-[14px] font-semibold text-[#f0e9d6] shadow-sm transition active:scale-[0.98] hover:bg-[#26331f]"
>
<Plus className="h-4 w-4" strokeWidth={2.4} />
{t.add} · {eur(discountedPrice(product))}
</button>
{!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]"
>
<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,20 +921,23 @@ export default function MenuPage() {
onOpen={openProduct}
onAdd={addToCart}
cart={cart}
viewOnly={viewOnly}
/>
<CartFlow
stage={stage}
setStage={setStage}
cart={cart}
setCart={setCart}
categories={categories}
lang={lang}
t={t}
onOpenProduct={openProduct}
siteSlug={siteSlug}
navigate={navigate}
/>
{!viewOnly && (
<CartFlow
stage={stage}
setStage={setStage}
cart={cart}
setCart={setCart}
categories={categories}
lang={lang}
t={t}
onOpenProduct={openProduct}
siteSlug={siteSlug}
navigate={navigate}
/>
)}
</div>
)
}