diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js index b835ad6..cd3af56 100644 --- a/src/server/templates/admin.js +++ b/src/server/templates/admin.js @@ -45,16 +45,19 @@ function buildEditQtyRow(name, qty) { minusBtn.dataset.adminAction = 'edit-qty'; minusBtn.dataset.mealName = name; minusBtn.dataset.delta = '-1'; + minusBtn.setAttribute('aria-label', 'Remove one ' + name); minusBtn.innerHTML = '−'; const span = document.createElement('span'); span.textContent = qty; + span.setAttribute('aria-label', 'Quantity of ' + name); const plusBtn = document.createElement('button'); plusBtn.type = 'button'; plusBtn.dataset.adminAction = 'edit-qty'; plusBtn.dataset.mealName = name; plusBtn.dataset.delta = '1'; + plusBtn.setAttribute('aria-label', 'Add one ' + name); plusBtn.textContent = '+'; wrap.appendChild(minusBtn); @@ -283,6 +286,7 @@ function downloadSummaryPdf() { const week = currentAdminWeek || WEEK; const btn = document.getElementById('admin-pdf-btn'); if (btn) btn.disabled = true; + window.FormUtils.clearStatus('admin'); fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data }))) .then(({ ok, status, data }) => { @@ -291,15 +295,29 @@ function downloadSummaryPdf() { if (typeof data.url === 'string' && data.url.startsWith('https://')) { window.location.href = data.url; } else { - alert('Could not fetch the summary PDF: invalid URL'); + window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', { + tone: 'error', + scope: 'admin', + }); } } else if (status === 404) { - alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.'); + window.FormUtils.showStatus( + 'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.', + { tone: 'info', scope: 'admin' } + ); } else { - alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error')); + window.FormUtils.showStatus( + 'Could not fetch the summary PDF: ' + (data.error || 'unknown error'), + { tone: 'error', scope: 'admin' } + ); } }) - .catch(() => alert('Could not fetch the summary PDF.')) + .catch(() => { + window.FormUtils.showStatus('Could not fetch the summary PDF.', { + tone: 'error', + scope: 'admin', + }); + }) .finally(() => { if (btn) btn.disabled = false; }); } @@ -340,11 +358,13 @@ function adminSaveEdit(email) { .filter(([, q]) => q > 0) .map(([name, quantity]) => ({ name, quantity })); if (items.length === 0) { + // confirm() retained deliberately for destructive zero-qty → delete guard. if (confirm('All quantities are zero. Delete this order instead?')) { adminDeleteOrder(email, ''); } return; } + window.FormUtils.clearStatus('admin'); fetch(ADMIN_URL, { method: 'PUT', headers: { ...adminHeaders(), 'Content-Type': 'application/json' }, @@ -352,19 +372,31 @@ function adminSaveEdit(email) { }) .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) .then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); }) - .catch(e => alert('Failed to update: ' + e.message)); + .catch(e => { + window.FormUtils.showStatus('Failed to update: ' + e.message, { + tone: 'error', + scope: 'admin', + }); + }); } function adminDeleteOrder(email, name) { const label = name ? name + ' (' + email + ')' : email; + // confirm() retained deliberately for destructive delete guard. if (!confirm('Delete order for ' + label + '?')) return; + window.FormUtils.clearStatus('admin'); fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), { method: 'DELETE', headers: adminHeaders(), }) .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) .then(() => loadWeekOrders(currentAdminWeek)) - .catch(e => alert('Failed to delete: ' + e.message)); + .catch(e => { + window.FormUtils.showStatus('Failed to delete: ' + e.message, { + tone: 'error', + scope: 'admin', + }); + }); } function handleAdminPanelClick(e) { diff --git a/src/server/templates/form.js b/src/server/templates/form.js index d37fa23..7a61bae 100644 --- a/src/server/templates/form.js +++ b/src/server/templates/form.js @@ -47,7 +47,32 @@ function employeePrice(retail) { return empPrice; } -window.FormUtils = { escapeHtml, employeePrice }; +function clearStatus(scope) { + const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; + const alertEl = document.getElementById(prefix + '-alert'); + const infoEl = document.getElementById(prefix + '-info'); + if (alertEl) alertEl.textContent = ''; + if (infoEl) infoEl.textContent = ''; +} + +function showStatus(message, options) { + const opts = options || {}; + const tone = opts.tone || 'error'; + const scope = opts.scope || 'form'; + const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; + const alertEl = document.getElementById(prefix + '-alert'); + const infoEl = document.getElementById(prefix + '-info'); + if (!alertEl || !infoEl) return; + if (tone === 'info') { + alertEl.textContent = ''; + infoEl.textContent = message || ''; + } else { + infoEl.textContent = ''; + alertEl.textContent = message || ''; + } +} + +window.FormUtils = { escapeHtml, employeePrice, showStatus, clearStatus }; function syncGoogleCredential() { window.googleCredential = googleCredential; @@ -183,6 +208,36 @@ function buildMealTagsEl(meal) { return tagsEl; } +function removeDescToggle(card) { + const existing = card.querySelector('.meal-desc-toggle'); + if (existing) existing.remove(); +} + +function attachDescToggleIfOverflow(card) { + const descEl = card.querySelector('.meal-desc'); + if (!descEl || descEl.classList.contains('is-hidden')) return; + removeDescToggle(card); + if (descEl.scrollHeight <= descEl.clientHeight + 1) return; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'meal-desc-toggle'; + btn.textContent = 'More'; + btn.setAttribute('aria-expanded', 'false'); + btn.addEventListener('click', () => { + const expanded = descEl.classList.toggle('is-expanded'); + btn.textContent = expanded ? 'Less' : 'More'; + btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); + descEl.after(btn); +} + +function scheduleDescToggle(card) { + requestAnimationFrame(() => { + attachDescToggleIfOverflow(card); + }); +} + function updateMealCard(card, meal, i) { const qty = quantities[i] || 0; card.dataset.mealIndex = String(i); @@ -202,9 +257,12 @@ function updateMealCard(card, meal, i) { card.querySelector('.meal-name').textContent = meal.name; const descEl = card.querySelector('.meal-desc'); + removeDescToggle(card); + descEl.classList.remove('is-expanded'); if (meal.description) { descEl.textContent = meal.description; descEl.classList.remove('is-hidden'); + scheduleDescToggle(card); } else { descEl.textContent = ''; descEl.classList.add('is-hidden'); @@ -220,6 +278,12 @@ function updateMealCard(card, meal, i) { const qtyInput = card.querySelector('.qty'); qtyInput.value = qty; + qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name); + + const minusBtn = card.querySelector('[data-qty-delta="-1"]'); + const plusBtn = card.querySelector('[data-qty-delta="1"]'); + if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name); + if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name); const disabled = formClosed && !isAdmin; card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; }); @@ -318,20 +382,38 @@ function updateTotal() { } async function submitOrder() { - if (formClosed && !isAdmin) { alert('Orders are closed.'); return; } + clearStatus('form'); + + if (formClosed && !isAdmin) { + showStatus('Orders are closed.', { tone: 'error', scope: 'form' }); + return; + } let payload; let successName; if (CONFIG.authMode === 'google') { - if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; } + if (!googleCredential || !googleUser) { + showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' }); + return; + } payload = { google_id_token: googleCredential, items: buildItems() }; successName = googleUser.name; } else { - const name = document.getElementById('emp-name').value.trim(); - const email = document.getElementById('emp-email').value.trim(); - if (!name) { alert('Please enter your name.'); return; } - if (!email) { alert('Please enter your email.'); return; } + const nameEl = document.getElementById('emp-name'); + const emailEl = document.getElementById('emp-email'); + const name = nameEl.value.trim(); + const email = emailEl.value.trim(); + if (!name) { + showStatus('Please enter your name.', { tone: 'error', scope: 'form' }); + nameEl.focus(); + return; + } + if (!email) { + showStatus('Please enter your email.', { tone: 'error', scope: 'form' }); + emailEl.focus(); + return; + } payload = { employee_name: name, employee_email: email, items: buildItems() }; successName = name; } @@ -359,45 +441,50 @@ async function submitOrder() { const footer = document.querySelector('.sticky-footer'); if (footer) footer.classList.add('is-hidden'); try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} + const successHeading = document.getElementById('success-heading'); + if (successHeading) successHeading.focus(); } else { - alert(data.error || 'Something went wrong.'); + showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' }); btn.disabled = false; btn.textContent = 'Submit Order'; } } catch (e) { - alert('Failed to submit. Check your connection and try again.'); + showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' }); btn.disabled = false; btn.textContent = 'Submit Order'; } } +function bindEmpNameChange(select) { + if (select.dataset.boundChange === '1') return; + select.dataset.boundChange = '1'; + select.addEventListener('change', () => { + const selected = select.options[select.selectedIndex]; + document.getElementById('emp-email').value = selected?.dataset?.email || ''; + clearStatus('form'); + }); +} + +function populateEmpSelect(select, employees) { + select.innerHTML = ''; + employees.forEach(emp => { + const opt = document.createElement('option'); + opt.value = emp.name; + opt.dataset.email = emp.email; + opt.textContent = emp.name; + select.appendChild(opt); + }); + bindEmpNameChange(select); +} + async function loadRoster() { + const select = document.getElementById('emp-name'); try { const res = await fetch(ROSTER_URL); const data = await res.json(); - const select = document.getElementById('emp-name'); - select.innerHTML = ''; - (data.employees || ROSTER).forEach(emp => { - const opt = document.createElement('option'); - opt.value = emp.name; - opt.dataset.email = emp.email; - opt.textContent = emp.name; - select.appendChild(opt); - }); - select.addEventListener('change', () => { - const selected = select.options[select.selectedIndex]; - document.getElementById('emp-email').value = selected?.dataset?.email || ''; - }); + populateEmpSelect(select, data.employees || ROSTER); } catch (e) { - const select = document.getElementById('emp-name'); - select.innerHTML = ''; - ROSTER.forEach(emp => { - const opt = document.createElement('option'); - opt.value = emp.name; - opt.dataset.email = emp.email; - opt.textContent = emp.name; - select.appendChild(opt); - }); + populateEmpSelect(select, ROSTER); } } @@ -585,11 +672,17 @@ function init() { cats.forEach(cat => { const btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : ''); + const isActive = cat === 'All Meals'; + btn.className = 'btn btn-chip' + (isActive ? ' active' : ''); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); btn.textContent = cat; btn.addEventListener('click', () => { - filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active')); + filtersEl.querySelectorAll('button').forEach(b => { + b.classList.remove('active'); + b.setAttribute('aria-pressed', 'false'); + }); btn.classList.add('active'); + btn.setAttribute('aria-pressed', 'true'); renderMeals(); }); filtersEl.appendChild(btn); diff --git a/tests/fixtures/menu.json b/tests/fixtures/menu.json index ee0671b..5b52de1 100644 --- a/tests/fixtures/menu.json +++ b/tests/fixtures/menu.json @@ -19,7 +19,7 @@ "price": 14.5, "calories": "620", "protein": "35g", - "description": "With onions", + "description": "Stir-fried beef tenderloin with onions, tomatoes, and soy sauce, served over french fries with a side of steamed white rice and aji amarillo. A long fixture blurb used to exercise the three-line clamp and the More expand control in accessibility tests.", "dietary_tags": [], "image_url": "", "is_new": true