// 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'); const catModal = document.getElementById('modal-cat'); 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 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 || ''; 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 = ''; availField.checked = true; } itemModal.showModal(); nameField.focus(); } function closeItemModal() { itemModal.close(); } 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'); if (catData) { title.textContent = '✏️ Edit Category'; idField.value = catData.id || 0; nameField.value = catData.name || ''; descField.value = catData.description || ''; } else { title.textContent = '✨ Add Category'; idField.value = 0; nameField.value = ''; descField.value = ''; } catModal.showModal(); nameField.focus(); } function closeCategoryModal() { catModal.close(); } 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(); } // Applies a menu_enabled state to the toggle button and its alert banner — // shared by the click handler below and, implicitly, the server-rendered // initial state (see menu.php), so the two never drift apart. function applyMenuEnabledState(enabled) { const btn = document.getElementById('btn-toggle-menu'); btn.textContent = enabled ? 'Disable Menu' : 'Enable Menu'; btn.classList.toggle('btn-danger', enabled); btn.classList.toggle('btn-outline', !enabled); btn.dataset.enabled = enabled ? '1' : '0'; document.getElementById('menu-disabled-alert').hidden = enabled; } // Modal listeners document.getElementById('btn-new-item').addEventListener('click', () => openItemModal()); document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal()); document.getElementById('btn-toggle-menu').addEventListener('click', async (e) => { const btn = e.currentTarget; const isCurrentlyEnabled = btn.dataset.enabled === '1'; // Only the disabling direction needs a confirm — it immediately hides // the menu from every visitor, where re-enabling it is harmless. if (isCurrentlyEnabled && !confirm('Hide the menu from the public site? Visitors will see a "menu being updated" message until you re-enable it.')) { return; } btn.disabled = true; const fd = new FormData(); fd.append('action', 'toggle_menu_enabled'); fd.append('csrf_token', csrfToken); try { const data = await postForm(fd); if (data.success) { applyMenuEnabledState(data.menu_enabled); } else { alert(data.error || 'Failed to update menu visibility.'); } } catch { alert('Network error. Please try again.'); } finally { btn.disabled = false; } }); 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); // 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('.item-row'); 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 }); return; } // Move Item Up/Down if (target.classList.contains('btn-move-item')) { const direction = target.dataset.direction; const row = target.closest('.item-row'); const rowsContainer = row.parentElement; const sibling = direction === 'up' ? row.previousElementSibling : row.nextElementSibling; if (!sibling) return; // already at this edge; button should be disabled anyway target.disabled = true; const fd = new FormData(); fd.append('action', 'move_item'); fd.append('item_id', row.dataset.itemId); fd.append('direction', direction); fd.append('csrf_token', csrfToken); try { const data = await postForm(fd); if (!data.success) { alert(data.error || 'Failed to reorder item.'); target.disabled = false; return; } if (direction === 'up') { rowsContainer.insertBefore(row, sibling); } else { rowsContainer.insertBefore(sibling, row); } // Re-derives disabled state from DOM position rather than re-enabling // `target` unconditionally, since the swap may have just moved it // onto an edge (e.g. into the first/last slot) where it should stay disabled. refreshMoveButtonStates(rowsContainer); } catch { alert('Network error. Please try again.'); target.disabled = false; } return; } // Move Category Up/Down if (target.classList.contains('btn-move-cat')) { const direction = target.dataset.direction; const panel = target.closest('.category-panel'); const container = panel.parentElement; const sibling = direction === 'up' ? panel.previousElementSibling : panel.nextElementSibling; if (!sibling) return; target.disabled = true; const fd = new FormData(); fd.append('action', 'move_category'); fd.append('category_id', target.dataset.id); fd.append('direction', direction); fd.append('csrf_token', csrfToken); try { const data = await postForm(fd); if (!data.success) { alert(data.error || 'Failed to reorder category.'); target.disabled = false; return; } if (direction === 'up') { container.insertBefore(panel, sibling); } else { container.insertBefore(sibling, panel); } refreshCategoryMoveButtonStates(); } catch { alert('Network error. Please try again.'); target.disabled = false; } return; } // Toggle Availability — the status badge itself is the button now, so // `target` IS the badge; no separate "Mark Sold Out" control to sync. if (target.classList.contains('btn-toggle-item')) { const row = target.closest('.item-row'); 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) { target.className = `status-badge btn-toggle-item ${data.status_class}`; target.textContent = data.status_badge; target.title = `Click to mark as ${data.is_available ? 'sold out' : 'available'}`; 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('.item-row'); const rowsContainer = row?.parentElement; 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(); refreshMoveButtonStates(rowsContainer); }, 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(); refreshCategoryMoveButtonStates(); }, 200); } } catch { alert('Failed to delete category.'); } } }); // Sets the up/down move buttons' disabled state from each row's actual DOM // position, rather than tracking indices by hand — first row can't move up, // last can't move down, everything in between can do either. Call this // after any insert/remove/reorder in a category's item-rows container. function refreshMoveButtonStates(rowsContainer) { if (!rowsContainer) return; const rows = [...rowsContainer.querySelectorAll('.item-row[data-item-id]')]; rows.forEach((row, index) => { const upBtn = row.querySelector('.btn-move-item[data-direction="up"]'); const downBtn = row.querySelector('.btn-move-item[data-direction="down"]'); if (upBtn) upBtn.disabled = index === 0; if (downBtn) downBtn.disabled = index === rows.length - 1; }); } // Same idea as refreshMoveButtonStates(), for category panels. function refreshCategoryMoveButtonStates() { const panels = [...document.querySelectorAll('#categories-container .category-panel')]; panels.forEach((panel, index) => { const upBtn = panel.querySelector('.btn-move-cat[data-direction="up"]'); const downBtn = panel.querySelector('.btn-move-cat[data-direction="down"]'); if (upBtn) upBtn.disabled = index === 0; if (downBtn) downBtn.disabled = index === panels.length - 1; }); } function itemRowHtml(item, isAvail) { const badgeHtml = item.badge ? `${escapeHtml(item.badge)}` : ''; return `
${escapeHtml(item.name)}${badgeHtml}
${escapeHtml(item.description || '')}
${escapeHtml(item.price)}
`; } function updateOrInsertItemRow(item, isNew) { const isAvail = parseInt(item.is_available, 10) === 1; let existingRow = document.getElementById(`item-row-${item.id}`); // An edit can reassign an item to a different category — when that // happens the row needs to move grids, not just update in place, so // detach it here and let it fall through to the insert path below. if (existingRow && existingRow.closest('.item-rows')?.id !== `item-rows-${item.category_id}`) { const oldRowsContainer = existingRow.parentElement; existingRow.remove(); existingRow = null; refreshMoveButtonStates(oldRowsContainer); if (oldRowsContainer && !oldRowsContainer.querySelector('.item-row[data-item-id]')) { oldRowsContainer.innerHTML = '

No items in this category yet.

'; } } if (existingRow) { // Update existing row existingRow.dataset.item = JSON.stringify(item); const statusBadge = existingRow.querySelector('.status-badge'); statusBadge.className = `status-badge btn-toggle-item ${isAvail ? 'active' : 'inactive'}`; statusBadge.textContent = isAvail ? '● Available' : '○ Sold Out'; statusBadge.title = `Click to mark as ${isAvail ? 'sold out' : 'available'}`; existingRow.querySelector('.item-name-cell').textContent = item.name; existingRow.querySelector('.item-desc-cell').textContent = item.description || ''; existingRow.querySelector('.item-price-cell').textContent = item.price; const nameRow = existingRow.querySelector('.item-name-row'); nameRow.querySelector('.menu-item-badge')?.remove(); if (item.badge) { nameRow.insertAdjacentHTML('beforeend', `${escapeHtml(item.badge)}`); } return; } // Item was created or moved to this category — it always joins at the // end (see next_item_sort_order() server-side), so just append it. const rowsContainer = document.getElementById(`item-rows-${item.category_id}`); if (!rowsContainer) return; rowsContainer.querySelector('.item-grid-empty')?.remove(); const row = document.createElement('div'); row.className = 'item-row'; row.id = `item-row-${item.id}`; row.dataset.itemId = item.id; row.dataset.item = JSON.stringify(item); row.innerHTML = itemRowHtml(item, isAvail); rowsContainer.appendChild(row); refreshMoveButtonStates(rowsContainer); } // 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 || '')}

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 || ''; 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(); refreshCategoryMoveButtonStates(); }