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>
This commit is contained in:
@@ -45,9 +45,13 @@ def _run_migrations():
|
||||
"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_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:
|
||||
|
||||
@@ -26,8 +26,10 @@ class Site(Base):
|
||||
|
||||
# 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_hours_en = Column(String, nullable=True)
|
||||
menu_hours_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)
|
||||
|
||||
@@ -39,9 +39,10 @@ def get_menu(site_slug: str, db: Session = Depends(get_db)):
|
||||
data = json.loads(snapshot.snapshot_json)
|
||||
data["menu_mode"] = site.menu_mode
|
||||
data["restaurant"] = {
|
||||
"name": site.name,
|
||||
"name": site.menu_display_name,
|
||||
"tagline": {"en": site.menu_tagline_en, "gr": site.menu_tagline_gr},
|
||||
"hours": {"en": site.menu_hours_en, "gr": site.menu_hours_gr},
|
||||
"blurb": {"en": site.menu_blurb_en, "gr": site.menu_blurb_gr},
|
||||
"hours": site.menu_hours,
|
||||
"headerImageUrl": site.menu_header_image_url,
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -16,10 +16,12 @@ class SiteUpdate(BaseModel):
|
||||
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_hours_en: str | None = None
|
||||
menu_hours_gr: str | None = None
|
||||
menu_blurb_en: str | None = None
|
||||
menu_blurb_gr: str | None = None
|
||||
menu_hours: str | None = None
|
||||
|
||||
|
||||
class SiteOut(BaseModel):
|
||||
@@ -38,10 +40,12 @@ class SiteOut(BaseModel):
|
||||
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_hours_en: str | None
|
||||
menu_hours_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}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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,
|
||||
} 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'
|
||||
|
||||
// Used when a site hasn't configured a field yet (API sends nulls until set in sysadmin)
|
||||
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 }
|
||||
@@ -121,7 +115,12 @@ function SheetHandle() {
|
||||
|
||||
// ── Hero ──────────────────────────────────────────────────────────────────────
|
||||
function Hero({ lang, setLang, restaurant }) {
|
||||
const r = { ...RESTAURANT_FALLBACK, ...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">
|
||||
{/* Language toggle */}
|
||||
@@ -141,27 +140,22 @@ function Hero({ lang, setLang, restaurant }) {
|
||||
</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]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.headerImageUrl ? (
|
||||
{headerImageUrl ? (
|
||||
<img
|
||||
src={r.headerImageUrl}
|
||||
alt={r.name}
|
||||
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]">
|
||||
{r.name}
|
||||
{name}
|
||||
</h1>
|
||||
)}
|
||||
{tagline && (
|
||||
<div className="mt-1.5 font-sans text-[14px] font-medium uppercase tracking-[0.18em] text-[#9caf88]">
|
||||
{r.tagline?.[lang] || RESTAURANT_FALLBACK.tagline[lang] || ''}
|
||||
{tagline}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ornament */}
|
||||
<div className="mx-auto my-4 flex w-40 items-center gap-2">
|
||||
@@ -170,17 +164,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] || RESTAURANT_FALLBACK.blurb[lang] || ''}
|
||||
{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>
|
||||
|
||||
@@ -35,10 +35,12 @@ export default function SiteDetailPage() {
|
||||
|
||||
// Menu settings form state
|
||||
const [menuMode, setMenuMode] = useState('order')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [taglineEn, setTaglineEn] = useState('')
|
||||
const [taglineGr, setTaglineGr] = useState('')
|
||||
const [hoursEn, setHoursEn] = useState('')
|
||||
const [hoursGr, setHoursGr] = useState('')
|
||||
const [blurbEn, setBlurbEn] = useState('')
|
||||
const [blurbGr, setBlurbGr] = useState('')
|
||||
const [hours, setHours] = useState('')
|
||||
const [headerImageFile, setHeaderImageFile] = useState(null)
|
||||
const [uploadingHeaderImage, setUploadingHeaderImage] = useState(false)
|
||||
|
||||
@@ -177,10 +179,12 @@ export default function SiteDetailPage() {
|
||||
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,
|
||||
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)
|
||||
@@ -347,10 +351,12 @@ export default function SiteDetailPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuMode(site.menu_mode || 'order')
|
||||
setDisplayName(site.menu_display_name || '')
|
||||
setTaglineEn(site.menu_tagline_en || '')
|
||||
setTaglineGr(site.menu_tagline_gr || '')
|
||||
setHoursEn(site.menu_hours_en || '')
|
||||
setHoursGr(site.menu_hours_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"
|
||||
@@ -365,6 +371,10 @@ export default function SiteDetailPage() {
|
||||
{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>
|
||||
@@ -655,6 +665,20 @@ export default function SiteDetailPage() {
|
||||
</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>
|
||||
@@ -680,27 +704,38 @@ export default function SiteDetailPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Hours (EN)</label>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Blurb (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hoursEn}
|
||||
onChange={e => setHoursEn(e.target.value)}
|
||||
placeholder="Open today · 12:00 – 23:30"
|
||||
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">Hours (GR)</label>
|
||||
<label className="block text-xs text-gray-400 mb-1.5">Blurb (GR)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hoursGr}
|
||||
onChange={e => setHoursGr(e.target.value)}
|
||||
placeholder="Ανοιχτά σήμερα · 12:00 – 23:30"
|
||||
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>
|
||||
<p className="text-xs text-gray-500">Leave a field empty to clear it from the menu header.</p>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user