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

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