// 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 ? 'Mark Sold Out' : 'Mark 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 || '')}

Status Item Price Tag Order Actions
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(); }