From f1bbf59548f5bccdc48fb9d2445f3d0fdf58ff5f Mon Sep 17 00:00:00 2001 From: David Dyer Date: Sun, 30 Aug 2026 21:35:22 -0400 Subject: [PATCH] Add online menu management, modernized and hardened Adds a public menu page (menu.php) and an admin CRUD interface (admin/menu.php + admin/menu.js) backed by new menu_categories and menu_items tables, replacing the "menu lives on Facebook" placeholder on the homepage. The admin JS/PHP came out of an AI-assisted pass that used outdated JS (var, .then() chains, string-built HTML) and had some rough edges security-wise, so this also: - Rewrites the admin JS to modern ES (const/let, async/await, template literals, dataset API) and splits it out of admin/menu.php into admin/menu.js for proper separation of concerns - Adds declare(strict_types=1) and PHP 8 match-based action dispatch in place of an if-chain - Validates category_id/item_id/category_id server-side before use, so a bad ID returns a clean JSON error instead of an uncaught PDOException or a null item breaking the client - Rejects non-string POST fields instead of crashing trim() on them - Replaces the category-save handler's full-page location.reload() with in-place DOM updates (panel insert/update, resort by sort_order, keep the item modal's category dropdown in sync), so every admin action is now AJAX with no reload - Sets data-name/data-desc via dataset assignment rather than string- interpolating them into an HTML attribute, closing a stored-XSS path through the category name/description fields Removes seed-menu.php, a leftover stub with no working code left in it after the AI's one-time seeding run. Co-Authored-By: Claude Sonnet 5 --- admin/index.php | 4 + admin/menu.js | 416 ++++++++++++++++++++++++++++++++++++++++++++++++ admin/menu.php | 385 ++++++++++++++++++++++++++++++++++++++++++++ inc/db.php | 36 +++-- inc/menu.php | 135 ++++++++++++++++ index.php | 24 +-- menu.php | 202 +++++++++++++++++++++++ styles.css | 207 +++++++++++++++++++++++- 8 files changed, 1382 insertions(+), 27 deletions(-) create mode 100644 admin/menu.js create mode 100644 admin/menu.php create mode 100644 inc/menu.php create mode 100644 menu.php diff --git a/admin/index.php b/admin/index.php index a1bec99..33a6335 100644 --- a/admin/index.php +++ b/admin/index.php @@ -76,6 +76,10 @@ $today = date('Y-m-d'); The Old Fitz Admin +
Logged in as View Public Site ↗ diff --git a/admin/menu.js b/admin/menu.js new file mode 100644 index 0000000..33bde7b --- /dev/null +++ b/admin/menu.js @@ -0,0 +1,416 @@ +// Menu Management admin page: category/item CRUD via fetch, modal handling. +// The CSRF token is handed off from PHP via so +// this file stays pure static JS with no server-side templating in it. + +const csrfToken = document.body.dataset.csrfToken; + +// Item Modal helpers +const itemModal = document.getElementById('modal-item-backdrop'); +const catModal = document.getElementById('modal-cat-backdrop'); + +function openItemModal(itemData = null) { + const title = document.getElementById('modal-item-title'); + const idField = document.getElementById('item-field-id'); + const nameField = document.getElementById('item-field-name'); + const catField = document.getElementById('item-field-cat'); + const priceField = document.getElementById('item-field-price'); + const badgeField = document.getElementById('item-field-badge'); + const descField = document.getElementById('item-field-desc'); + const sortField = document.getElementById('item-field-sort'); + const availField = document.getElementById('item-field-available'); + + if (itemData) { + title.textContent = '✏️ Edit Menu Item'; + idField.value = itemData.id || 0; + nameField.value = itemData.name || ''; + catField.value = itemData.category_id || catField.options[0]?.value || 0; + priceField.value = itemData.price || ''; + badgeField.value = itemData.badge || ''; + descField.value = itemData.description || ''; + sortField.value = itemData.sort_order || 0; + availField.checked = parseInt(itemData.is_available, 10) === 1; + } else { + title.textContent = '✨ Add Menu Item'; + idField.value = 0; + nameField.value = ''; + priceField.value = ''; + badgeField.value = ''; + descField.value = ''; + sortField.value = 0; + availField.checked = true; + } + + itemModal.classList.add('is-open'); + nameField.focus(); +} + +function closeItemModal() { + itemModal.classList.remove('is-open'); +} + +function openCategoryModal(catData = null) { + const title = document.getElementById('modal-cat-title'); + const idField = document.getElementById('cat-field-id'); + const nameField = document.getElementById('cat-field-name'); + const descField = document.getElementById('cat-field-desc'); + const sortField = document.getElementById('cat-field-sort'); + + if (catData) { + title.textContent = '✏️ Edit Category'; + idField.value = catData.id || 0; + nameField.value = catData.name || ''; + descField.value = catData.description || ''; + sortField.value = catData.sort_order || 0; + } else { + title.textContent = '✨ Add Category'; + idField.value = 0; + nameField.value = ''; + descField.value = ''; + sortField.value = 0; + } + + catModal.classList.add('is-open'); + nameField.focus(); +} + +function closeCategoryModal() { + catModal.classList.remove('is-open'); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} + +async function postForm(formData) { + const response = await fetch('menu.php', { + method: 'POST', + body: formData, + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + return response.json(); +} + +// Modal listeners +document.getElementById('btn-new-item').addEventListener('click', () => openItemModal()); +document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal()); +document.getElementById('btn-create-first-cat')?.addEventListener('click', () => openCategoryModal()); +document.getElementById('btn-close-item-modal').addEventListener('click', closeItemModal); +document.getElementById('btn-cancel-item').addEventListener('click', closeItemModal); +document.getElementById('btn-close-cat-modal').addEventListener('click', closeCategoryModal); +document.getElementById('btn-cancel-cat').addEventListener('click', closeCategoryModal); + +// Close on backdrop click +itemModal.addEventListener('click', (e) => { if (e.target === itemModal) closeItemModal(); }); +catModal.addEventListener('click', (e) => { if (e.target === catModal) closeCategoryModal(); }); + +// Close on Escape key +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeItemModal(); + closeCategoryModal(); + } +}); + +// Form submit: Save Item (Async Fetch) +document.getElementById('form-item').addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = document.getElementById('btn-save-item'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const res = await postForm(new FormData(e.target)); + if (res.success) { + closeItemModal(); + updateOrInsertItemRow(res.item, res.is_new); + } else { + alert(res.error || 'Failed to save item.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Item'; + } +}); + +// Form submit: Save Category (Async Fetch) +document.getElementById('form-cat').addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = document.getElementById('btn-save-cat'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const res = await postForm(new FormData(e.target)); + if (res.success) { + closeCategoryModal(); + updateOrInsertCategoryPanel(res.category); + } else { + alert(res.error || 'Failed to save category.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Category'; + } +}); + +// Delegate Table Actions: Edit, Toggle Availability, Delete +document.getElementById('categories-container').addEventListener('click', async (e) => { + const { target } = e; + + // Edit Item + if (target.classList.contains('btn-edit-item')) { + const row = target.closest('tr'); + openItemModal(JSON.parse(row.dataset.item)); + return; + } + + // Edit Category + if (target.classList.contains('btn-edit-cat')) { + openCategoryModal({ + id: target.dataset.id, + name: target.dataset.name, + description: target.dataset.desc, + sort_order: target.dataset.sort + }); + return; + } + + // Toggle Availability (Mark Sold Out / Mark Available) + if (target.classList.contains('btn-toggle-item')) { + const row = target.closest('tr'); + const statusBadge = row.querySelector('.status-badge'); + target.disabled = true; + + const fd = new FormData(); + fd.append('action', 'toggle_item'); + fd.append('item_id', target.dataset.id); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success) { + statusBadge.className = `status-badge ${data.status_class}`; + statusBadge.textContent = data.status_badge; + target.textContent = data.btn_text; + + const currentData = JSON.parse(row.dataset.item); + currentData.is_available = data.is_available ? 1 : 0; + row.dataset.item = JSON.stringify(currentData); + } + } catch { + alert('Failed to update status.'); + } finally { + target.disabled = false; + } + return; + } + + // Delete Item + if (target.classList.contains('btn-delete-item')) { + if (!confirm('Are you sure you want to delete this menu item?')) return; + const row = target.closest('tr'); + + const fd = new FormData(); + fd.append('action', 'delete_item'); + fd.append('item_id', target.dataset.id); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success && row) { + row.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; + row.style.opacity = '0'; + row.style.transform = 'translateX(-10px)'; + setTimeout(() => row.remove(), 200); + } + } catch { + alert('Failed to delete item.'); + } + return; + } + + // Delete Category + if (target.classList.contains('btn-delete-cat')) { + if (!confirm('Are you sure you want to delete this category and ALL its menu items?')) return; + const panel = document.getElementById(`cat-panel-${target.dataset.id}`); + + const fd = new FormData(); + fd.append('action', 'delete_category'); + fd.append('category_id', target.dataset.id); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success && panel) { + panel.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; + panel.style.opacity = '0'; + panel.style.transform = 'translateY(-10px)'; + setTimeout(() => panel.remove(), 200); + } + } catch { + alert('Failed to delete category.'); + } + } +}); + +function updateOrInsertItemRow(item, isNew) { + const isAvail = parseInt(item.is_available, 10) === 1; + const existingRow = document.getElementById(`item-row-${item.id}`); + + if (existingRow) { + // Update existing row + existingRow.dataset.item = JSON.stringify(item); + const statusBadge = existingRow.querySelector('.status-badge'); + statusBadge.className = `status-badge ${isAvail ? 'active' : 'inactive'}`; + statusBadge.textContent = isAvail ? '● Available' : '○ Sold Out / Hidden'; + + existingRow.querySelector('.item-name-cell').textContent = item.name; + existingRow.querySelector('.item-desc-cell').textContent = item.description || ''; + existingRow.querySelector('.item-price-cell').textContent = item.price; + existingRow.querySelector('.item-sort-cell').textContent = item.sort_order || 0; + + const badgeCell = existingRow.querySelector('.item-badge-cell'); + badgeCell.innerHTML = item.badge + ? `${escapeHtml(item.badge)}` + : '—'; + + existingRow.querySelector('.btn-toggle-item').textContent = isAvail ? 'Sold Out' : 'Available'; + return; + } + + // Item was created or moved — insert into matching category table + const catTable = document.getElementById(`cat-table-${item.category_id}`); + if (!catTable) return; + + const tbody = catTable.querySelector('tbody'); + tbody.querySelector('.no-items-row')?.remove(); + + const tr = document.createElement('tr'); + tr.id = `item-row-${item.id}`; + tr.dataset.itemId = item.id; + tr.dataset.item = JSON.stringify(item); + + tr.innerHTML = ` + ${isAvail ? '● Available' : '○ Sold Out / Hidden'} + ${escapeHtml(item.name)}
${escapeHtml(item.description || '')}
+ ${escapeHtml(item.price)} + ${item.badge ? `${escapeHtml(item.badge)}` : '—'} + ${item.sort_order || 0} +
+ + + +
+ `; + + tbody.appendChild(tr); +} + +// Static shell for a category panel. Only text-content positions use +// escapeHtml() here — it does not escape quote characters, so it must never +// be used to build an HTML *attribute* string (see applyCategoryData below, +// which sets data-name/data-desc directly via dataset instead). +function categoryPanelHtml(category) { + return ` +
+
+

+ ${escapeHtml(category.name)} +

+

${escapeHtml(category.description || '')}

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
StatusItemPriceTagOrderActions
No items in this category yet.
+
+ `; +} + +// Sets id/data-* on a category panel via direct property assignment (never +// via HTML-string interpolation), so arbitrary characters in the name or +// description can't break out of an attribute. +function applyCategoryData(panel, category) { + panel.id = `cat-panel-${category.id}`; + panel.dataset.catId = category.id; + panel.dataset.sortOrder = category.sort_order || 0; + + const editBtn = panel.querySelector('.btn-edit-cat'); + editBtn.dataset.id = category.id; + editBtn.dataset.name = category.name; + editBtn.dataset.desc = category.description || ''; + editBtn.dataset.sort = category.sort_order || 0; + + panel.querySelector('.btn-delete-cat').dataset.id = category.id; +} + +// Keeps category panels in the same order the server would render them in +// (by sort_order, then id) after an in-place create/edit. +function resortCategoryPanels() { + const container = document.getElementById('categories-container'); + const panels = [...container.querySelectorAll('.category-panel')]; + panels.sort((a, b) => { + const bySortOrder = (Number(a.dataset.sortOrder) || 0) - (Number(b.dataset.sortOrder) || 0); + return bySortOrder !== 0 ? bySortOrder : (Number(a.dataset.catId) || 0) - (Number(b.dataset.catId) || 0); + }); + panels.forEach((panel) => container.appendChild(panel)); +} + +// Keeps the "Menu Category" dropdown in the item modal in sync so a +// newly-created or renamed category is immediately usable without a reload. +function syncCategoryOption(category) { + const select = document.getElementById('item-field-cat'); + let option = select.querySelector(`option[value="${category.id}"]`); + if (!option) { + option = document.createElement('option'); + option.value = category.id; + select.appendChild(option); + } + option.textContent = category.name; +} + +function updateOrInsertCategoryPanel(category) { + document.getElementById('empty-menu-panel')?.remove(); + + let panel = document.getElementById(`cat-panel-${category.id}`); + + if (panel) { + panel.querySelector('.cat-name-heading span').textContent = category.name; + panel.querySelector('.cat-desc-text').textContent = category.description || ''; + } else { + panel = document.createElement('div'); + panel.className = 'panel category-panel'; + panel.innerHTML = categoryPanelHtml(category); + document.getElementById('categories-container').appendChild(panel); + } + + applyCategoryData(panel, category); + syncCategoryOption(category); + resortCategoryPanels(); +} diff --git a/admin/menu.php b/admin/menu.php new file mode 100644 index 0000000..0530b0a --- /dev/null +++ b/admin/menu.php @@ -0,0 +1,385 @@ + false, 'error' => 'Please choose a valid menu category.']; + } + + $name = post_str($post, 'name'); + $price = post_str($post, 'price'); + if ($name === '' || $price === '') { + return ['success' => false, 'error' => 'Item name and price are required.']; + } + + $itemId = !empty($post['item_id']) ? (int)$post['item_id'] : null; + if ($itemId !== null && ($itemId <= 0 || !get_menu_item_by_id($itemId))) { + return ['success' => false, 'error' => 'Menu item not found.']; + } + + $savedId = save_menu_item([ + 'category_id' => $categoryId, + 'name' => $name, + 'description' => post_str($post, 'description'), + 'price' => $price, + 'badge' => post_str($post, 'badge'), + 'is_available' => isset($post['is_available']) ? 1 : 0, + 'sort_order' => (int)($post['sort_order'] ?? 0), + ], $itemId); + + return ['success' => true, 'item' => get_menu_item_by_id($savedId), 'is_new' => $itemId === null]; +} + +function handle_toggle_item(array $post): array { + $itemId = (int)($post['item_id'] ?? 0); + if ($itemId <= 0 || !get_menu_item_by_id($itemId)) { + return ['success' => false, 'error' => 'Menu item not found.']; + } + + toggle_menu_item_availability($itemId); + $item = get_menu_item_by_id($itemId); + + return [ + 'success' => true, + 'is_available' => (bool)$item['is_available'], + 'status_badge' => $item['is_available'] ? '● Available' : '○ Sold Out / Hidden', + 'status_class' => $item['is_available'] ? 'active' : 'inactive', + 'btn_text' => $item['is_available'] ? 'Sold Out' : 'Available', + ]; +} + +function handle_delete_item(array $post): array { + $itemId = (int)($post['item_id'] ?? 0); + if ($itemId <= 0) { + return ['success' => false, 'error' => 'Menu item not found.']; + } + + delete_menu_item($itemId); + return ['success' => true]; +} + +function handle_save_category(array $post): array { + $name = post_str($post, 'name'); + if ($name === '') { + return ['success' => false, 'error' => 'Category name is required.']; + } + + $catId = !empty($post['category_id']) ? (int)$post['category_id'] : null; + if ($catId !== null && ($catId <= 0 || !get_category_by_id($catId))) { + return ['success' => false, 'error' => 'Category not found.']; + } + + $savedId = save_category([ + 'name' => $name, + 'description' => post_str($post, 'description'), + 'sort_order' => (int)($post['sort_order'] ?? 0), + ], $catId); + + return ['success' => true, 'category' => get_category_by_id($savedId), 'is_new' => $catId === null]; +} + +function handle_delete_category(array $post): array { + $catId = (int)($post['category_id'] ?? 0); + if ($catId <= 0) { + return ['success' => false, 'error' => 'Category not found.']; + } + + delete_category($catId); + return ['success' => true]; +} + +function handle_menu_action(string $action, array $post): array { + return match ($action) { + 'save_item' => handle_save_item($post), + 'toggle_item' => handle_toggle_item($post), + 'delete_item' => handle_delete_item($post), + 'save_category' => handle_save_category($post), + 'delete_category' => handle_delete_category($post), + default => ['success' => false, 'error' => 'Unknown action'], + }; +} + +// Handle AJAX actions (JSON response) +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + header('Content-Type: application/json; charset=utf-8'); + + $csrfToken = $_POST['csrf_token'] ?? null; + if (!verify_csrf_token(is_string($csrfToken) ? $csrfToken : null)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']); + exit; + } + + $action = is_string($_POST['action'] ?? null) ? $_POST['action'] : ''; + echo json_encode(handle_menu_action($action, $_POST)); + exit; +} + +$categories = get_all_categories(); +$menuGrouped = get_full_menu(false); +?> + + + + + + Menu Management | The Old Fitz Admin + + + + + + + + + +
+
+
+

Food & Drink

+

Online Menu Management

+
+ +
+ + +
+ +
+ +
+
+

No categories or menu items yet.

+ +
+
+ + +
+
+
+

+ +

+

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + '> + + + + + + + + + + +
StatusItemPriceTagOrderActions
No items in this category yet.
+ + ● Available + + ○ Sold Out / Hidden + + + +
+ +
+
+ + + + + + — + + + + +
+ + + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + diff --git a/inc/db.php b/inc/db.php index 9a8ca36..28fc9e0 100644 --- a/inc/db.php +++ b/inc/db.php @@ -1,4 +1,6 @@ prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')"); $insert->execute(['admin', $defaultPassHash]); } - - // Seed default Labor Day announcement row if table is empty - $stmtAnn = $pdo->query("SELECT COUNT(*) as count FROM announcements"); - $annCount = $stmtAnn->fetchColumn(); - if ($annCount == 0) { - $insertAnn = $pdo->prepare(" - INSERT INTO announcements ( - is_active, type, headline, message, start_date, end_date, show_banner, show_in_hours - ) VALUES ( - 1, 'holiday', 'Labor Day Closure', 'The Old Fitz will be closed Thursday, Sept 3 through Saturday, Sept 5 for the holiday weekend. Doors reopen Thursday, Sept 10 at 4:00 PM!', NULL, NULL, 1, 1 - ) - "); - $insertAnn->execute(); - } } diff --git a/inc/menu.php b/inc/menu.php new file mode 100644 index 0000000..248a36b --- /dev/null +++ b/inc/menu.php @@ -0,0 +1,135 @@ +query("SELECT * FROM menu_categories ORDER BY sort_order ASC, id ASC"); + $categories = $catStmt->fetchAll() ?: []; + + $itemSql = "SELECT * FROM menu_items"; + if ($availableOnly) { + $itemSql .= " WHERE is_available = 1"; + } + $itemSql .= " ORDER BY sort_order ASC, id ASC"; + + $itemStmt = $pdo->query($itemSql); + $items = $itemStmt->fetchAll() ?: []; + + // Group items by category_id + $grouped = []; + foreach ($categories as $cat) { + $cat['items'] = []; + $grouped[$cat['id']] = $cat; + } + + foreach ($items as $item) { + if (isset($grouped[$item['category_id']])) { + $grouped[$item['category_id']]['items'][] = $item; + } + } + + return array_values($grouped); +} + +function get_all_categories(): array { + $pdo = get_db(); + $stmt = $pdo->query("SELECT * FROM menu_categories ORDER BY sort_order ASC, id ASC"); + return $stmt->fetchAll() ?: []; +} + +function get_category_by_id(int $id): ?array { + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT * FROM menu_categories WHERE id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + return $res ?: null; +} + +function category_exists(int $id): bool { + if ($id <= 0) { + return false; + } + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT 1 FROM menu_categories WHERE id = ?"); + $stmt->execute([$id]); + return (bool)$stmt->fetchColumn(); +} + +function save_category(array $data, ?int $id = null): int { + $pdo = get_db(); + $name = trim($data['name'] ?? ''); + $description = trim($data['description'] ?? ''); + $sortOrder = (int)($data['sort_order'] ?? 0); + + if ($id && $id > 0) { + $stmt = $pdo->prepare("UPDATE menu_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?"); + $stmt->execute([$name, $description, $sortOrder, $id]); + return $id; + } else { + $stmt = $pdo->prepare("INSERT INTO menu_categories (name, description, sort_order) VALUES (?, ?, ?)"); + $stmt->execute([$name, $description, $sortOrder]); + return (int)$pdo->lastInsertId(); + } +} + +function delete_category(int $id): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("DELETE FROM menu_categories WHERE id = ?"); + return $stmt->execute([$id]); +} + +function get_menu_item_by_id(int $id): ?array { + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT * FROM menu_items WHERE id = ?"); + $stmt->execute([$id]); + $res = $stmt->fetch(); + return $res ?: null; +} + +function save_menu_item(array $data, ?int $id = null): int { + $pdo = get_db(); + $categoryId = (int)($data['category_id'] ?? 0); + $name = trim($data['name'] ?? ''); + $description = trim($data['description'] ?? ''); + $price = trim($data['price'] ?? ''); + $badge = trim($data['badge'] ?? ''); + $isAvailable = !empty($data['is_available']) ? 1 : 0; + $sortOrder = (int)($data['sort_order'] ?? 0); + + if ($id && $id > 0) { + $stmt = $pdo->prepare(" + UPDATE menu_items SET + category_id = ?, name = ?, description = ?, price = ?, + badge = ?, is_available = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([$categoryId, $name, $description, $price, $badge, $isAvailable, $sortOrder, $id]); + return $id; + } else { + $stmt = $pdo->prepare(" + INSERT INTO menu_items (category_id, name, description, price, badge, is_available, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$categoryId, $name, $description, $price, $badge, $isAvailable, $sortOrder]); + return (int)$pdo->lastInsertId(); + } +} + +function toggle_menu_item_availability(int $id): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("UPDATE menu_items SET is_available = CASE WHEN is_available = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + return $stmt->execute([$id]); +} + +function delete_menu_item(int $id): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("DELETE FROM menu_items WHERE id = ?"); + return $stmt->execute([$id]); +} diff --git a/index.php b/index.php index 138f65c..b4aaac5 100644 --- a/index.php +++ b/index.php @@ -46,7 +46,7 @@ $siteUrl = rtrim(SITE_URL, '/');
@@ -135,9 +135,9 @@ $siteUrl = rtrim(SITE_URL, '/');

On Tap Thu–Sat

@@ -220,10 +220,10 @@ $siteUrl = rtrim(SITE_URL, '/');

The Menu

Pub food and a full bar, Thursday through Saturday.

-

The current menu lives behind the bar and gets posted on our Facebook page. Stop in, or send us a message and we'll get you sorted before you arrive.

+

We serve traditional Irish staples, shareable plates, and hearty comfort food until 9 PM, with bar snacks staying out later. Explore our full kitchen menu or check Facebook for weekend chalkboard features.

@@ -295,4 +295,4 @@ $siteUrl = rtrim(SITE_URL, '/'); - + \ No newline at end of file diff --git a/menu.php b/menu.php new file mode 100644 index 0000000..2f26373 --- /dev/null +++ b/menu.php @@ -0,0 +1,202 @@ + + + + + + + + Pub Menu | The Old Fitz Public House + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + diff --git a/styles.css b/styles.css index 222ba0d..064121f 100644 --- a/styles.css +++ b/styles.css @@ -764,7 +764,7 @@ /* menu band */ .menu-band .container { - max-width: 47.5rem; + max-width: 60rem; text-align: center; } @@ -1236,4 +1236,209 @@ .empty-state p { font-size: 0.95rem; margin-bottom: 1.25rem; + } + + /* admin nav links */ + .admin-nav-links { + display: flex; + gap: 1.25rem; + align-items: center; + } + + .admin-nav-links a { + font-family: var(--font-mono); + font-size: 0.82rem; + letter-spacing: 0.05em; + text-decoration: none; + color: var(--ink-muted); + padding: 0.35rem 0.65rem; + border-radius: 0.125rem; + transition: color .15s ease, background .15s ease; + } + + .admin-nav-links a:hover { + color: var(--gold-bright); + background: var(--surface); + } + + .admin-nav-links a.active { + color: var(--gold-bright); + background: var(--surface); + border-bottom: 1px solid var(--gold); + } + + /* public online menu section */ + .menu-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 2.5rem; + margin-top: 3rem; + text-align: left; + } + + .menu-category { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 0.25rem; + padding: 2rem; + } + + .menu-category-header { + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--rule); + } + + .menu-category-header h3 { + font-size: 1.45rem; + color: var(--gold-bright); + } + + .menu-category-header p { + margin-top: 0.35rem; + font-size: 0.88rem; + color: var(--ink-muted); + } + + .menu-items-list { + display: flex; + flex-direction: column; + gap: 1.4rem; + } + + .menu-item-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .menu-item-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; + } + + .menu-item-name { + font-family: var(--font-serif); + font-weight: 600; + font-size: 1.05rem; + color: var(--ink); + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .menu-item-price { + font-family: var(--font-mono); + font-weight: 500; + font-size: 0.95rem; + color: var(--gold-bright); + white-space: nowrap; + } + + .menu-item-desc { + font-size: 0.88rem; + color: var(--ink-muted); + line-height: 1.45; + } + + .menu-item-badge { + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + border-radius: 0.125rem; + background: rgba(201, 163, 92, 0.15); + border: 1px solid var(--gold-dim); + color: var(--gold-bright); + font-weight: normal; + } + + /* ---------- admin modal dialogs ---------- */ + .admin-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 12, 10, 0.8); + backdrop-filter: blur(0.25rem); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1.5rem; + opacity: 0; + pointer-events: none; + transition: opacity .2s ease; + } + + .admin-modal-backdrop.is-open { + opacity: 1; + pointer-events: auto; + } + + .admin-modal { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 0.25rem; + width: 100%; + max-width: 36rem; + max-height: 90vh; + overflow-y: auto; + padding: 2rem; + box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6); + transform: translateY(0.75rem); + transition: transform .2s ease; + } + + .admin-modal-backdrop.is-open .admin-modal { + transform: translateY(0); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--rule); + } + + .modal-header h2 { + font-size: 1.35rem; + color: var(--gold-bright); + margin: 0; + } + + .modal-close { + background: transparent; + border: none; + color: var(--ink-muted); + font-size: 1.5rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; + transition: color .15s ease; + } + + .modal-close:hover { + color: var(--gold-bright); + } + + .admin-toolbar { + display: flex; + justify-content: flex-start; + gap: 0.75rem; + align-items: center; + margin-bottom: 2rem; + } + + /* menu page specific headers & spacing */ + .menu-page-header { + padding-block: 4.5rem 2.5rem; + } + + .menu-page-content { + padding-bottom: 5.5rem; } \ No newline at end of file