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