feat: add photo support to food menu items
- Add photos and featured_photo columns to menu_items table - Update MenuEditor with photo upload functionality - Add click-to-set-featured-photo interaction - Update menu API endpoints to handle photos
This commit is contained in:
+50
-4
@@ -8,7 +8,7 @@ type Slide = { id: number; title: string; subtitle: string; image_url: string |
|
||||
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: number[] };
|
||||
type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean };
|
||||
type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number };
|
||||
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number };
|
||||
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number; photos: string[]; featured_photo: string | null };
|
||||
type Place = { id: number; name: string; description: string; photos: string[]; featured_photo: string | null; distance_km: string; visit_time: string; suggestion: string; active: boolean; sort_order: number };
|
||||
type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean };
|
||||
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
|
||||
@@ -1408,7 +1408,7 @@ function MenuSection({ items, setItems, images, onToast }: any) {
|
||||
return (
|
||||
<div>
|
||||
<SecHead title="🍽️ Menu" count={items.length}
|
||||
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length })} size="sm">+ New Item</Btn>} />
|
||||
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length, photos: [], featured_photo: null })} size="sm">+ New Item</Btn>} />
|
||||
{sorted.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
|
||||
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow }}>
|
||||
{/* Drag handle instruction */}
|
||||
@@ -1523,16 +1523,62 @@ function MenuSection({ items, setItems, images, onToast }: any) {
|
||||
}
|
||||
|
||||
function MenuEditor({ item, setItem, onSave, onCancel }: any) {
|
||||
const i = { ...item };
|
||||
const [localPhotos, setLocalPhotos] = useState<string[]>(item.photos || []);
|
||||
const i = { ...item, photos: localPhotos };
|
||||
|
||||
const addPhotos = async (files: FileList) => {
|
||||
const newPhotos: string[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
const b64 = await readFileBase64(f);
|
||||
try {
|
||||
const res = await fetch('/api/admin/images', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: f.name, data: b64, category: 'menu', room_id: null, location_target: `menu-${i.id || 'new'}` }),
|
||||
});
|
||||
if (res.ok) { const d = await res.json(); newPhotos.push(d.data.data); }
|
||||
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
|
||||
}
|
||||
setLocalPhotos(prev => [...prev, ...newPhotos]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 460, width: '90%' }}>
|
||||
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{i.id ? 'Edit Item' : 'New Item'}</h3>
|
||||
<div style={{ display: 'grid', gap: '14px' }}>
|
||||
<I label="Category" value={i.category} onChange={(v: string) => setItem({ ...i, category: v })} />
|
||||
<I label="Name" value={i.name} onChange={(v: string) => setItem({ ...i, name: v })} />
|
||||
<TA label="Description" value={i.description || ''} onChange={(v: string) => setItem({ ...i, description: v })} />
|
||||
<I label="Price" value={i.price} onChange={(v: string) => setItem({ ...i, price: v })} />
|
||||
|
||||
{/* Photo upload */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<Btn onClick={() => document.getElementById(`menu-photos-${i.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
|
||||
<input id={`menu-photos-${i.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||||
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{localPhotos.map((ph, idx) => (
|
||||
<div key={idx} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={ph}
|
||||
alt=""
|
||||
onClick={() => setItem({ ...i, featured_photo: ph })}
|
||||
style={{
|
||||
width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: i.featured_photo === ph ? `2px solid ${C.gold}` : '1px solid #eee',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== idx))}
|
||||
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{localPhotos.length > 0 && <div style={{ fontSize: '11px', color: C.textLight }}>Click a photo to set as featured</div>}
|
||||
</div>
|
||||
|
||||
<Tgl label="Daily Special" checked={i.is_daily_special} onChange={(v: boolean) => setItem({ ...i, is_daily_special: v })} />
|
||||
<Tgl label="Active" checked={i.active} onChange={(v: boolean) => setItem({ ...i, active: v })} />
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, active, sort_order } = body;
|
||||
const { category, name, description, price, is_daily_special, active, sort_order, photos, featured_photo } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special, active, sort_order || 0, id]
|
||||
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7, photos = $8, featured_photo = $9
|
||||
WHERE id = $10 RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special, active, sort_order || 0, photos || [], featured_photo || null, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
||||
@@ -18,17 +18,17 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, sort_order } = body;
|
||||
const { category, name, description, price, is_daily_special, sort_order, photos, featured_photo } = body;
|
||||
|
||||
if (!category || !name || price === undefined || price === '') {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order, photos, featured_photo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[category, name, description || '', price, is_daily_special || false, sort_order || 0]
|
||||
[category, name, description || '', price, is_daily_special || false, sort_order || 0, photos || [], featured_photo || null]
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
|
||||
Reference in New Issue
Block a user