grubflip/admin/meals.html
Sarah a3331881d4 Add GrubFlip admin dashboard — meals, orders, trades pages
Built out the full restaurant owner admin dashboard:
- Dashboard overview with stats, recent trades, and top meals
- Meals management with card grid, add/edit/delete modals, search & filter
- Orders page with status tabs, accept/ready/complete workflow
- Trades activity page with search and stats overview
- Shared admin-app.js module (sidebar, API client, toast, auth helpers)
- All pages use existing design tokens, mobile-first responsive layout
- Demo data fallback when API endpoints aren't ready yet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:38:49 +00:00

542 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grubflip Admin — Meals</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<div class="admin-layout">
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation">
<div class="sidebar-brand">
<div class="logo-icon">GF</div>
<span>Grubflip</span>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section-title">Main</div>
<a href="dashboard.html" class="sidebar-link">
<span class="icon">&#x1f4ca;</span> Dashboard
</a>
<a href="meals.html" class="sidebar-link active">
<span class="icon">&#x1f354;</span> Meals
</a>
<a href="orders.html" class="sidebar-link">
<span class="icon">&#x1f4e6;</span> Orders
</a>
<a href="trades.html" class="sidebar-link">
<span class="icon">&#x1f504;</span> Trades
</a>
<div class="sidebar-section-title">Settings</div>
<a href="profile.html" class="sidebar-link">
<span class="icon">&#x1f3ea;</span> Restaurant Profile
</a>
<a href="settings.html" class="sidebar-link">
<span class="icon">&#x2699;&#xfe0f;</span> Settings
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar" id="userAvatar">DR</div>
<div class="user-info">
<div class="user-name" id="userName">Demo Restaurant</div>
<div class="user-role" id="userRole">Owner</div>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu">
&#9776;
</button>
<h1 class="topbar-title">Meals</h1>
</div>
<div class="topbar-right">
<button class="topbar-btn" aria-label="Sign out" id="logoutBtn">
&#x23fb;
</button>
</div>
</header>
<!-- Page Content -->
<div class="page-content">
<div class="page-header">
<h1>Manage Meals</h1>
<p class="subtitle">Add, edit, and manage your restaurant's meal listings.</p>
</div>
<!-- Toolbar -->
<div class="meals-toolbar">
<div class="search-box">
<span class="search-icon">&#x1f50d;</span>
<input type="text" class="form-input" id="searchInput" placeholder="Search meals...">
</div>
<div class="flex gap-3">
<select class="form-select" id="filterStatus" style="width: auto; min-width: 8rem;">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
</select>
<button class="btn btn-primary" id="addMealBtn">
+ Add Meal
</button>
</div>
</div>
<!-- Meals Grid -->
<div id="mealsContainer">
<div class="loading-overlay"><div class="spinner"></div></div>
</div>
</div>
</main>
</div>
<!-- Add/Edit Meal Modal -->
<div class="modal-overlay" id="mealModal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-header">
<h2 id="modalTitle">Add Meal</h2>
<button class="modal-close" id="modalClose" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form id="mealForm" novalidate>
<input type="hidden" id="mealId" value="">
<!-- Image Upload -->
<div class="form-group">
<label class="form-label">Photo</label>
<div class="img-preview-wrapper" id="imgPreview">
<div class="upload-label">
<span>&#x1f4f7;</span>
Tap to upload a photo
</div>
</div>
<input type="file" id="mealImage" accept="image/*" class="hidden">
</div>
<!-- Title -->
<div class="form-group">
<label for="mealTitle" class="form-label">Meal Title *</label>
<input type="text" id="mealTitle" class="form-input" placeholder="e.g. Spicy Ramen Bowl" required>
</div>
<!-- Description -->
<div class="form-group">
<label for="mealDesc" class="form-label">Description</label>
<textarea id="mealDesc" class="form-textarea" placeholder="Describe the meal, ingredients, what makes it special..."></textarea>
</div>
<!-- Category & Tags -->
<div class="form-row">
<div class="form-group">
<label for="mealCategory" class="form-label">Category</label>
<select id="mealCategory" class="form-select">
<option value="">Select category</option>
<option value="bowls">Bowls</option>
<option value="burgers">Burgers</option>
<option value="wraps">Wraps & Sandwiches</option>
<option value="salads">Salads</option>
<option value="pasta">Pasta</option>
<option value="tacos">Tacos & Burritos</option>
<option value="pizza">Pizza</option>
<option value="dessert">Desserts</option>
<option value="drinks">Drinks</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="mealCalories" class="form-label">Calories</label>
<input type="number" id="mealCalories" class="form-input" placeholder="e.g. 520">
</div>
</div>
<!-- Price & Availability -->
<div class="form-row">
<div class="form-group">
<label for="mealPrice" class="form-label">Price ($)</label>
<input type="number" id="mealPrice" class="form-input" placeholder="0.00" step="0.01" min="0">
</div>
<div class="form-group">
<label for="mealStatus" class="form-label">Status</label>
<select id="mealStatus" class="form-select">
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
</select>
</div>
</div>
<!-- Tags -->
<div class="form-group">
<label for="mealTags" class="form-label">Tags</label>
<input type="text" id="mealTags" class="form-input" placeholder="spicy, vegan, gluten-free (comma separated)">
<span class="form-hint">Comma-separated. Helps users find your meal.</span>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="modalCancel">Cancel</button>
<button class="btn btn-primary" id="modalSave">Save Meal</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal" style="max-width: 24rem;">
<div class="modal-header">
<h2>Delete Meal</h2>
<button class="modal-close" id="deleteModalClose" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteMealName"></strong>? This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="deleteCancelBtn">Cancel</button>
<button class="btn btn-danger" id="deleteConfirmBtn">Delete</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script src="js/admin-app.js"></script>
<script>
// --- Auth Guard ---
if (!AdminApp.requireAuth()) throw new Error('Not authenticated');
const user = AdminApp.getUser();
// Populate user sidebar
if (user.name) {
document.getElementById('userName').textContent = user.name;
document.getElementById('userAvatar').textContent = user.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
}
if (user.role) {
document.getElementById('userRole').textContent = user.role.charAt(0).toUpperCase() + user.role.slice(1);
}
// --- Init ---
AdminApp.initSidebar();
document.getElementById('logoutBtn').addEventListener('click', AdminApp.logout);
// --- State ---
let meals = [];
let editingMealId = null;
let deletingMealId = null;
// --- Demo Data ---
const DEMO_MEALS = [
{
id: 1, title: 'Spicy Ramen Bowl', description: 'Rich miso broth with hand-pulled noodles, chashu pork, soft egg, nori, and chili oil. Our most popular dish.', category: 'bowls', calories: 680, price: 14.99, status: 'active', tags: ['spicy', 'pork', 'noodles'], trades: 47, image: null, created_at: '2026-03-20T10:00:00Z'
},
{
id: 2, title: 'Chicken Burrito', description: 'Grilled chicken, cilantro lime rice, black beans, pico de gallo, sour cream, and guacamole in a flour tortilla.', category: 'tacos', calories: 720, price: 12.99, status: 'active', tags: ['chicken', 'mexican'], trades: 38, image: null, created_at: '2026-03-19T14:30:00Z'
},
{
id: 3, title: 'Veggie Power Bowl', description: 'Quinoa, roasted sweet potato, avocado, chickpeas, kale, tahini dressing. 100% plant-based goodness.', category: 'bowls', calories: 520, price: 13.49, status: 'active', tags: ['vegan', 'healthy', 'gluten-free'], trades: 31, image: null, created_at: '2026-03-18T09:15:00Z'
},
{
id: 4, title: 'BBQ Pulled Pork', description: 'Slow-smoked pulled pork with house-made BBQ sauce, coleslaw, and pickles on a brioche bun.', category: 'burgers', calories: 650, price: 11.99, status: 'active', tags: ['bbq', 'pork'], trades: 24, image: null, created_at: '2026-03-17T16:00:00Z'
},
{
id: 5, title: 'Greek Salad Wrap', description: 'Fresh romaine, feta, olives, cucumber, tomato, red onion, and Greek dressing in a spinach wrap.', category: 'wraps', calories: 380, price: 10.49, status: 'active', tags: ['healthy', 'vegetarian'], trades: 19, image: null, created_at: '2026-03-16T11:00:00Z'
},
{
id: 6, title: 'Truffle Mac & Cheese', description: 'Creamy four-cheese blend with truffle oil, topped with panko breadcrumbs. Ultimate comfort food.', category: 'pasta', calories: 780, price: 15.99, status: 'draft', tags: ['comfort', 'cheese', 'truffle'], trades: 0, image: null, created_at: '2026-03-26T08:00:00Z'
},
];
// --- Load Meals ---
async function loadMeals() {
try {
const data = await AdminApp.api('/admin/meals');
meals = data.meals || data;
} catch {
// API not ready — use demo data
meals = [...DEMO_MEALS];
}
renderMeals();
}
// --- Render ---
function renderMeals() {
const container = document.getElementById('mealsContainer');
const search = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('filterStatus').value;
let filtered = meals.filter(m => {
if (search && !m.title.toLowerCase().includes(search) && !(m.description || '').toLowerCase().includes(search)) return false;
if (statusFilter && m.status !== statusFilter) return false;
return true;
});
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="icon">&#x1f354;</div>
<h3>${search || statusFilter ? 'No meals match your filters' : 'No meals yet'}</h3>
<p>${search || statusFilter ? 'Try adjusting your search or filters.' : 'Add your first meal to get started on Grubflip.'}</p>
${!search && !statusFilter ? '<button class="btn btn-primary" onclick="openAddModal()">+ Add Your First Meal</button>' : ''}
</div>
`;
return;
}
const statusBadge = (status) => {
const map = { active: 'badge-success', draft: 'badge-default', paused: 'badge-warning' };
return `<span class="badge ${map[status] || 'badge-default'}">${status}</span>`;
};
const categoryLabel = (cat) => {
const map = { bowls: 'Bowls', burgers: 'Burgers', wraps: 'Wraps', salads: 'Salads', pasta: 'Pasta', tacos: 'Tacos', pizza: 'Pizza', dessert: 'Desserts', drinks: 'Drinks', other: 'Other' };
return map[cat] || cat || 'Uncategorized';
};
container.innerHTML = `
<div class="meals-grid">
${filtered.map(m => `
<div class="meal-card" data-id="${m.id}">
${m.image
? `<img class="meal-card-img" src="${AdminApp.escapeHtml(m.image)}" alt="${AdminApp.escapeHtml(m.title)}">`
: `<div class="meal-card-img-placeholder">&#x1f37d;</div>`
}
<div class="meal-card-body">
<div class="meal-card-title">${AdminApp.escapeHtml(m.title)}</div>
<div class="meal-card-meta">
<span>${categoryLabel(m.category)}</span>
${m.calories ? `<span>${m.calories} cal</span>` : ''}
${m.price ? `<span>$${Number(m.price).toFixed(2)}</span>` : ''}
</div>
<div class="meal-card-desc">${AdminApp.escapeHtml(m.description || '')}</div>
<div class="meal-card-footer">
${statusBadge(m.status)}
<div class="actions">
<button class="btn btn-ghost btn-sm" onclick="openEditModal(${m.id})" aria-label="Edit ${AdminApp.escapeHtml(m.title)}">&#x270f; Edit</button>
<button class="btn btn-ghost btn-sm" onclick="openDeleteModal(${m.id})" aria-label="Delete ${AdminApp.escapeHtml(m.title)}" style="color:var(--gf-error)">&#x1f5d1;</button>
</div>
</div>
</div>
</div>
`).join('')}
</div>
`;
}
// --- Search & Filter ---
document.getElementById('searchInput').addEventListener('input', renderMeals);
document.getElementById('filterStatus').addEventListener('change', renderMeals);
// --- Modal Helpers ---
const mealModal = document.getElementById('mealModal');
const deleteModal = document.getElementById('deleteModal');
function openModal(modal) {
modal.classList.add('visible');
document.body.style.overflow = 'hidden';
}
function closeModal(modal) {
modal.classList.remove('visible');
document.body.style.overflow = '';
}
function resetMealForm() {
document.getElementById('mealForm').reset();
document.getElementById('mealId').value = '';
document.getElementById('imgPreview').innerHTML = '<div class="upload-label"><span>&#x1f4f7;</span>Tap to upload a photo</div>';
editingMealId = null;
}
// --- Add Meal ---
function openAddModal() {
resetMealForm();
document.getElementById('modalTitle').textContent = 'Add Meal';
document.getElementById('modalSave').textContent = 'Save Meal';
openModal(mealModal);
}
document.getElementById('addMealBtn').addEventListener('click', openAddModal);
// --- Edit Meal ---
function openEditModal(id) {
const meal = meals.find(m => m.id === id);
if (!meal) return;
resetMealForm();
editingMealId = id;
document.getElementById('modalTitle').textContent = 'Edit Meal';
document.getElementById('modalSave').textContent = 'Update Meal';
document.getElementById('mealId').value = id;
document.getElementById('mealTitle').value = meal.title || '';
document.getElementById('mealDesc').value = meal.description || '';
document.getElementById('mealCategory').value = meal.category || '';
document.getElementById('mealCalories').value = meal.calories || '';
document.getElementById('mealPrice').value = meal.price || '';
document.getElementById('mealStatus').value = meal.status || 'active';
document.getElementById('mealTags').value = (meal.tags || []).join(', ');
if (meal.image) {
document.getElementById('imgPreview').innerHTML = `<img src="${AdminApp.escapeHtml(meal.image)}" alt="Preview">`;
}
openModal(mealModal);
}
// --- Delete Meal ---
function openDeleteModal(id) {
const meal = meals.find(m => m.id === id);
if (!meal) return;
deletingMealId = id;
document.getElementById('deleteMealName').textContent = meal.title;
openModal(deleteModal);
}
document.getElementById('deleteConfirmBtn').addEventListener('click', async () => {
if (!deletingMealId) return;
try {
await AdminApp.api(`/admin/meals/${deletingMealId}`, { method: 'DELETE' });
} catch {
// Demo mode — just remove locally
}
meals = meals.filter(m => m.id !== deletingMealId);
deletingMealId = null;
closeModal(deleteModal);
renderMeals();
AdminApp.toast('Meal deleted', 'success');
});
// --- Save Meal ---
document.getElementById('modalSave').addEventListener('click', async () => {
const title = document.getElementById('mealTitle').value.trim();
if (!title) {
document.getElementById('mealTitle').classList.add('error');
document.getElementById('mealTitle').focus();
return;
}
document.getElementById('mealTitle').classList.remove('error');
const mealData = {
title,
description: document.getElementById('mealDesc').value.trim(),
category: document.getElementById('mealCategory').value,
calories: parseInt(document.getElementById('mealCalories').value) || null,
price: parseFloat(document.getElementById('mealPrice').value) || null,
status: document.getElementById('mealStatus').value,
tags: document.getElementById('mealTags').value.split(',').map(t => t.trim()).filter(Boolean),
};
try {
if (editingMealId) {
const updated = await AdminApp.api(`/admin/meals/${editingMealId}`, {
method: 'PUT',
body: mealData,
});
if (updated) {
const idx = meals.findIndex(m => m.id === editingMealId);
if (idx !== -1) meals[idx] = { ...meals[idx], ...updated };
}
} else {
const created = await AdminApp.api('/admin/meals', {
method: 'POST',
body: mealData,
});
if (created) meals.unshift(created);
}
} catch {
// Demo mode — update locally
if (editingMealId) {
const idx = meals.findIndex(m => m.id === editingMealId);
if (idx !== -1) meals[idx] = { ...meals[idx], ...mealData };
} else {
const newMeal = {
id: Date.now(),
...mealData,
trades: 0,
image: null,
created_at: new Date().toISOString(),
};
meals.unshift(newMeal);
}
}
closeModal(mealModal);
renderMeals();
AdminApp.toast(editingMealId ? 'Meal updated' : 'Meal added', 'success');
editingMealId = null;
});
// --- Image Upload ---
const imgPreview = document.getElementById('imgPreview');
const imageInput = document.getElementById('mealImage');
imgPreview.addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
AdminApp.toast('Please select an image file', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
AdminApp.toast('Image must be under 5MB', 'error');
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
imgPreview.innerHTML = `<img src="${ev.target.result}" alt="Preview">`;
};
reader.readAsDataURL(file);
});
// --- Modal Close Handlers ---
document.getElementById('modalClose').addEventListener('click', () => closeModal(mealModal));
document.getElementById('modalCancel').addEventListener('click', () => closeModal(mealModal));
document.getElementById('deleteModalClose').addEventListener('click', () => closeModal(deleteModal));
document.getElementById('deleteCancelBtn').addEventListener('click', () => closeModal(deleteModal));
// Close on overlay click
mealModal.addEventListener('click', (e) => {
if (e.target === mealModal) closeModal(mealModal);
});
deleteModal.addEventListener('click', (e) => {
if (e.target === deleteModal) closeModal(deleteModal);
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal(mealModal);
closeModal(deleteModal);
}
});
// --- Boot ---
loadMeals();
</script>
</body>
</html>