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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user