Safari caches static assets far more aggressively than Chrome/Firefox and largely ignores heuristic revalidation, so a new deploy could keep showing the old CSS/JS indefinitely even on a hard refresh — no Cache-Control headers were set anywhere to guide it otherwise. inc/assets.php adds asset_version($path), which appends a ?v=<mtime> query string derived from the file's last-modified time. Every page that links styles.css or main.js (index.php, menu.php, and all five admin pages) now uses it, so the URL changes whenever the file's contents change and browsers always fetch the new version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
423 lines
18 KiB
PHP
423 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../inc/auth.php';
|
|
require_once __DIR__ . '/../inc/assets.php';
|
|
require_once __DIR__ . '/../inc/menu.php';
|
|
|
|
require_auth();
|
|
$user = get_logged_in_user();
|
|
|
|
/**
|
|
* Safely read a trimmed string from a POST payload, guarding against
|
|
* unexpected non-string input (e.g. a spoofed `name[]=` array field).
|
|
*/
|
|
function post_str(array $data, string $key, string $default = ''): string
|
|
{
|
|
$value = $data[$key] ?? $default;
|
|
return is_string($value) ? trim($value) : $default;
|
|
}
|
|
|
|
function handle_save_item(array $post): array
|
|
{
|
|
$categoryId = (int) ($post['category_id'] ?? 0);
|
|
if (!category_exists($categoryId)) {
|
|
return ['success' => 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,
|
|
], $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',
|
|
'status_class' => $item['is_available'] ? 'active' : 'inactive',
|
|
];
|
|
}
|
|
|
|
function handle_move_item(array $post): array
|
|
{
|
|
$itemId = (int) ($post['item_id'] ?? 0);
|
|
$direction = post_str($post, 'direction');
|
|
if ($itemId <= 0 || !get_menu_item_by_id($itemId)) {
|
|
return ['success' => false, 'error' => 'Menu item not found.'];
|
|
}
|
|
if (!in_array($direction, ['up', 'down'], true)) {
|
|
return ['success' => false, 'error' => 'Invalid direction.'];
|
|
}
|
|
|
|
return move_menu_item($itemId, $direction);
|
|
}
|
|
|
|
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'),
|
|
], $catId);
|
|
|
|
return ['success' => true, 'category' => get_category_by_id($savedId), 'is_new' => $catId === null];
|
|
}
|
|
|
|
function handle_move_category(array $post): array
|
|
{
|
|
$catId = (int) ($post['category_id'] ?? 0);
|
|
$direction = post_str($post, 'direction');
|
|
if ($catId <= 0 || !get_category_by_id($catId)) {
|
|
return ['success' => false, 'error' => 'Category not found.'];
|
|
}
|
|
if (!in_array($direction, ['up', 'down'], true)) {
|
|
return ['success' => false, 'error' => 'Invalid direction.'];
|
|
}
|
|
|
|
return move_category($catId, $direction);
|
|
}
|
|
|
|
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_toggle_menu_enabled(): array
|
|
{
|
|
set_menu_enabled(!is_menu_enabled());
|
|
return ['success' => true, 'menu_enabled' => is_menu_enabled()];
|
|
}
|
|
|
|
function handle_menu_action(string $action, array $post): array
|
|
{
|
|
return match ($action) {
|
|
'save_item' => handle_save_item($post),
|
|
'toggle_item' => handle_toggle_item($post),
|
|
'move_item' => handle_move_item($post),
|
|
'delete_item' => handle_delete_item($post),
|
|
'save_category' => handle_save_category($post),
|
|
'move_category' => handle_move_category($post),
|
|
'delete_category' => handle_delete_category($post),
|
|
'toggle_menu_enabled' => handle_toggle_menu_enabled(),
|
|
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);
|
|
$menuEnabled = is_menu_enabled();
|
|
?>
|
|
<!doctype html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Menu Management | The Old Fitz Admin</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=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
</head>
|
|
|
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
|
|
|
|
<nav class="admin-nav">
|
|
<div class="container admin-nav-inner">
|
|
<a href="index.php" class="admin-brand">
|
|
<img src="../assets/knot.png" alt="" class="knot">
|
|
<span>The Old Fitz Admin</span>
|
|
</a>
|
|
<div class="admin-nav-links">
|
|
<a href="index.php">Announcements</a>
|
|
<a href="menu.php" class="active">Menu</a>
|
|
<a href="users.php">Users</a>
|
|
<a href="hours.php">Hours</a>
|
|
</div>
|
|
<div class="admin-user">
|
|
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
|
|
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
<a href="logout.php">Sign Out</a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="dashboard-layout">
|
|
<div class="container">
|
|
<div class="dash-header">
|
|
<p class="eyebrow">Food & Drink</p>
|
|
<h1>Online Menu Management</h1>
|
|
</div>
|
|
|
|
<div class="admin-toolbar">
|
|
<button type="button" class="btn btn-primary btn-sm" id="btn-new-item">+ Add Menu Item</button>
|
|
<button type="button" class="btn btn-outline btn-sm" id="btn-new-cat">+ Add Category</button>
|
|
<button type="button" class="btn btn-sm <?= $menuEnabled ? 'btn-danger' : 'btn-outline' ?>" id="btn-toggle-menu" data-enabled="<?= $menuEnabled ? '1' : '0' ?>">
|
|
<?= $menuEnabled ? 'Disable Menu' : 'Enable Menu' ?>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="alert-error" id="menu-disabled-alert" role="status" <?= $menuEnabled ? 'hidden' : '' ?>>
|
|
Menu is disabled — the public menu page shows a "menu being updated" message instead of your items.
|
|
</div>
|
|
|
|
<div id="categories-container">
|
|
<?php if (empty($menuGrouped)): ?>
|
|
<div class="panel" id="empty-menu-panel">
|
|
<div class="empty-state">
|
|
<p>No categories or menu items yet.</p>
|
|
<button type="button" class="btn btn-primary btn-sm" id="btn-create-first-cat">+ Create First Category</button>
|
|
</div>
|
|
</div>
|
|
<?php else: ?>
|
|
<?php $catCount = count($menuGrouped); ?>
|
|
<?php foreach ($menuGrouped as $catIndex => $category): ?>
|
|
<div class="panel category-panel" id="cat-panel-<?= (int) $category['id'] ?>" data-cat-id="<?= (int) $category['id'] ?>" data-sort-order="<?= (int) $category['sort_order'] ?>">
|
|
<div class="panel-head">
|
|
<h2 class="cat-name-heading">
|
|
<span><?= htmlspecialchars($category['name']) ?></span>
|
|
</h2>
|
|
<p class="muted cat-desc-text"><?= htmlspecialchars($category['description'] ?? '') ?></p>
|
|
<div class="row-actions">
|
|
<div class="action-stack">
|
|
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="up" data-id="<?= (int) $category['id'] ?>" aria-label="Move <?= htmlspecialchars($category['name']) ?> category up" <?= $catIndex === 0 ? 'disabled' : '' ?>>↑</button>
|
|
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="down" data-id="<?= (int) $category['id'] ?>" aria-label="Move <?= htmlspecialchars($category['name']) ?> category down" <?= $catIndex === $catCount - 1 ? 'disabled' : '' ?>>↓</button>
|
|
</div>
|
|
<div class="action-stack">
|
|
<button type="button" class="btn btn-outline btn-sm btn-edit-cat"
|
|
data-id="<?= (int) $category['id'] ?>"
|
|
data-name="<?= htmlspecialchars($category['name']) ?>"
|
|
data-desc="<?= htmlspecialchars($category['description'] ?? '') ?>">
|
|
Edit Category
|
|
</button>
|
|
<button type="button" class="btn btn-danger btn-sm btn-delete-cat" data-id="<?= (int) $category['id'] ?>">
|
|
Delete Category
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="item-grid-wrap">
|
|
<div class="item-grid-head" aria-hidden="true">
|
|
<span class="col-status">Status</span>
|
|
<span class="col-item">Item</span>
|
|
<span class="col-price">Price</span>
|
|
<span class="col-actions">Actions</span>
|
|
</div>
|
|
<div class="item-rows" id="item-rows-<?= (int) $category['id'] ?>">
|
|
<?php if (empty($category['items'])): ?>
|
|
<p class="muted item-grid-empty">No items in this category yet.</p>
|
|
<?php else: ?>
|
|
<?php $itemCount = count($category['items']); ?>
|
|
<?php foreach ($category['items'] as $itemIndex => $item): ?>
|
|
<div class="item-row" id="item-row-<?= (int) $item['id'] ?>" data-item-id="<?= (int) $item['id'] ?>" data-item='<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>'>
|
|
<div data-label="Status">
|
|
<button type="button" class="status-badge btn-toggle-item <?= $item['is_available'] ? 'active' : 'inactive' ?>" data-id="<?= (int) $item['id'] ?>" title="Click to mark as <?= $item['is_available'] ? 'sold out' : 'available' ?>">
|
|
<?= $item['is_available'] ? '● Available' : '○ Sold Out' ?>
|
|
</button>
|
|
</div>
|
|
<div data-label="Item">
|
|
<span class="item-name-row">
|
|
<strong class="item-name-cell"><?= htmlspecialchars($item['name']) ?></strong>
|
|
<?php if (!empty($item['badge'])): ?>
|
|
<span class="menu-item-badge"><?= htmlspecialchars($item['badge']) ?></span>
|
|
<?php endif; ?>
|
|
</span>
|
|
<div class="muted item-desc-cell">
|
|
<?= htmlspecialchars($item['description'] ?? '') ?>
|
|
</div>
|
|
</div>
|
|
<div class="font-mono item-price-cell" data-label="Price">
|
|
<?= htmlspecialchars($item['price']) ?>
|
|
</div>
|
|
<div data-label="Actions">
|
|
<div class="row-actions">
|
|
<div class="action-stack">
|
|
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="up" aria-label="Move <?= htmlspecialchars($item['name']) ?> up" <?= $itemIndex === 0 ? 'disabled' : '' ?>>↑</button>
|
|
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="down" aria-label="Move <?= htmlspecialchars($item['name']) ?> down" <?= $itemIndex === $itemCount - 1 ? 'disabled' : '' ?>>↓</button>
|
|
</div>
|
|
<div class="action-stack">
|
|
<button type="button" class="btn btn-outline btn-sm btn-edit-item">Edit</button>
|
|
<button type="button" class="btn btn-danger btn-sm btn-delete-item" data-id="<?= (int) $item['id'] ?>">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Modal Backdrop: Menu Item -->
|
|
<div class="admin-modal-backdrop" id="modal-item-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-item-title">
|
|
<div class="admin-modal">
|
|
<div class="modal-header">
|
|
<h2 id="modal-item-title">✨ Add Menu Item</h2>
|
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-item-modal">×</button>
|
|
</div>
|
|
<form id="form-item">
|
|
<input type="hidden" name="action" value="save_item">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
|
<input type="hidden" name="item_id" id="item-field-id" value="0">
|
|
|
|
<div class="form-grid">
|
|
<div class="form-group full-width">
|
|
<label class="checkbox-label checkbox-label-lg">
|
|
<input type="checkbox" name="is_available" id="item-field-available" value="1" checked>
|
|
<strong>Available (Uncheck to Mark as Sold Out / Hidden)</strong>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="item-field-cat">Menu Category</label>
|
|
<select id="item-field-cat" name="category_id" class="form-control" required>
|
|
<?php foreach ($categories as $cat): ?>
|
|
<option value="<?= (int) $cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="item-field-name">Item Name</label>
|
|
<input type="text" id="item-field-name" name="name" class="form-control" placeholder="e.g. Fish & Chips" required>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="item-field-price">Price</label>
|
|
<input type="text" id="item-field-price" name="price" class="form-control" placeholder="e.g. $15 or $7 / pint" required>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="item-field-badge">Badge / Tag (Optional)</label>
|
|
<input type="text" id="item-field-badge" name="badge" class="form-control" placeholder="e.g. Local Favorite, On Tap">
|
|
</div>
|
|
|
|
<div class="form-group full-width">
|
|
<label for="item-field-desc">Description</label>
|
|
<textarea id="item-field-desc" name="description" class="form-control" placeholder="Ingredients, preparation, flavor notes..."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-primary" id="btn-save-item">Save Item</button>
|
|
<button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Backdrop: Category -->
|
|
<div class="admin-modal-backdrop" id="modal-cat-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-cat-title">
|
|
<div class="admin-modal">
|
|
<div class="modal-header">
|
|
<h2 id="modal-cat-title">✨ Add Category</h2>
|
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">×</button>
|
|
</div>
|
|
<form id="form-cat">
|
|
<input type="hidden" name="action" value="save_category">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
|
<input type="hidden" name="category_id" id="cat-field-id" value="0">
|
|
|
|
<div class="form-grid">
|
|
<div class="form-group full-width">
|
|
<label for="cat-field-name">Category Name</label>
|
|
<input type="text" id="cat-field-name" name="name" class="form-control" placeholder="e.g. Fare to Share" required>
|
|
</div>
|
|
|
|
<div class="form-group full-width">
|
|
<label for="cat-field-desc">Description / Subtitle (Optional)</label>
|
|
<input type="text" id="cat-field-desc" name="description" class="form-control" placeholder="e.g. Small bites and shareable plates.">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-primary" id="btn-save-cat">Save Category</button>
|
|
<button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="menu.js" defer></script>
|
|
|
|
</body>
|
|
|
|
</html>
|