/* FORM_JS_MARKER */ const CONFIG = window.CONFIG; const MEALS = CONFIG.meals; const ROSTER = CONFIG.roster; const SUBMIT_URL = CONFIG.submitUrl; const STATUS_URL = CONFIG.statusUrl; const ROSTER_URL = CONFIG.rosterUrl; const ADMIN_URL = CONFIG.adminUrl; const ADMIN_PDF_URL = CONFIG.adminPdfUrl; const WEEK = CONFIG.week; const BULK_DISCOUNT = CONFIG.bulkDiscount; const COMPANY_SUBSIDY = CONFIG.companySubsidy; const GOOGLE_CLIENT_ID = CONFIG.googleClientId || ''; window.MEALS = MEALS; window.WEEK = WEEK; window.ADMIN_URL = ADMIN_URL; window.ADMIN_PDF_URL = ADMIN_PDF_URL; const quantities = {}; let formClosed = false; let formStatusData = null; let isAdmin = false; let adminWeeks = []; let googleCredential = null; let googleUser = null; let reopenAtMs = null; let countdownTimer = null; let mealsListDelegationBound = false; const GOOGLE_AUTH_MAX_ATTEMPTS = 200; window.googleCredential = null; function employeePrice(retail) { if (!retail) return 0; const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100; const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100; return empPrice; } 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 = { employeePrice, showStatus, clearStatus }; function syncGoogleCredential() { window.googleCredential = googleCredential; } function buildItems() { return Object.entries(quantities).map(([i, qty]) => ({ name: MEALS[i].name, retail_price: MEALS[i].price, quantity: qty, })); } function waitForGoogleAuth(attempt = 0) { if (typeof google !== 'undefined' && google.accounts && google.accounts.id) { initGoogleAuth(); } else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) { setTimeout(() => waitForGoogleAuth(attempt + 1), 50); } else { const signIn = document.getElementById('g-signin-btn'); if (signIn) { signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.'; } console.error('Google Identity Services failed to load.'); } } function initGoogleAuth() { google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: handleCredentialResponse, auto_select: true, }); google.accounts.id.renderButton( document.getElementById('g-signin-btn'), { theme: 'outline', size: 'large', text: 'signin_with', width: 300 } ); } function handleCredentialResponse(response) { googleCredential = response.credential; syncGoogleCredential(); const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); googleUser = { name: payload.name, email: payload.email }; document.getElementById('auth-overlay').classList.add('is-hidden'); document.getElementById('app').classList.remove('is-hidden'); const footer = document.getElementById('sticky-footer'); if (footer) footer.classList.remove('is-hidden'); document.getElementById('user-name').textContent = payload.name; document.getElementById('user-email').textContent = payload.email; if (payload.picture) { const avatar = document.getElementById('user-avatar'); avatar.src = payload.picture; avatar.classList.remove('is-hidden'); } updateTotal(); checkDuplicateOrder(); checkAdmin().then(() => { if (formClosed && !isAdmin) showClosedOverlay(); }); } function signOut() { isAdmin = false; const ab = document.getElementById('admin-btn'); if (ab) ab.classList.add('is-hidden'); const banner = document.getElementById('admin-closed-banner'); if (banner) banner.remove(); googleCredential = null; googleUser = null; syncGoogleCredential(); if (CONFIG.authMode === 'google' && typeof google !== 'undefined' && google.accounts) { google.accounts.id.disableAutoSelect(); } document.getElementById('auth-overlay').classList.remove('is-hidden'); document.getElementById('app').classList.add('is-hidden'); const footer = document.getElementById('sticky-footer'); if (footer) footer.classList.add('is-hidden'); if (formClosed) showClosedOverlay(); } function checkDuplicateOrder() { try { if (localStorage.getItem('lastOrderWeek') === WEEK) { document.getElementById('duplicate-warning').classList.remove('is-hidden'); } } catch (e) {} } function getActiveFilter() { return document.querySelector('.filters button.active')?.textContent || 'All Meals'; } function mealMatchesFilter(meal, activeFilter, search) { if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return false; if (search && !meal.name.toLowerCase().includes(search)) return false; return true; } function buildMealMetaHtml(meal) { const hasDiscount = CONFIG.hasDiscount && meal.price; const empPrice = employeePrice(meal.price); const meta = document.createElement('div'); meta.className = 'meal-meta'; if (hasDiscount) { const retail = document.createElement('span'); retail.className = 'price-retail'; retail.textContent = '$' + meal.price.toFixed(2); meta.appendChild(retail); const employee = document.createElement('span'); employee.className = 'price-employee'; employee.textContent = '$' + empPrice.toFixed(2); meta.appendChild(employee); } else if (meal.price) { meta.appendChild(document.createTextNode('$' + meal.price.toFixed(2))); } else { meta.appendChild(document.createTextNode('—')); } meta.appendChild(document.createTextNode(' · ' + (meal.calories || '?') + ' cal · ' + (meal.protein || '?') + ' protein')); return meta; } function buildMealTagsEl(meal) { const tagsEl = document.createElement('div'); tagsEl.className = 'meal-tags'; if (meal.is_new) { const span = document.createElement('span'); span.className = 'new'; span.textContent = 'NEW'; tagsEl.appendChild(span); } (meal.dietary_tags || []).forEach(t => { const span = document.createElement('span'); span.textContent = t; tagsEl.appendChild(span); }); 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); card.classList.toggle('selected', qty > 0); const img = card.querySelector('.meal-img'); if (meal.image_url && /^https?:\/\//i.test(meal.image_url)) { img.src = meal.image_url; img.alt = meal.name; img.classList.remove('is-hidden'); } else { img.removeAttribute('src'); img.alt = ''; img.classList.add('is-hidden'); } 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'); } const metaEl = card.querySelector('.meal-meta'); const newMeta = buildMealMetaHtml(meal); metaEl.replaceWith(newMeta); const tagsEl = card.querySelector('.meal-tags'); const newTags = buildMealTagsEl(meal); tagsEl.replaceWith(newTags); 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; }); qtyInput.disabled = disabled; } function createMealCard(meal, i) { const tpl = document.getElementById('meal-card-tpl'); const card = tpl.content.firstElementChild.cloneNode(true); updateMealCard(card, meal, i); return card; } function renderMeals() { const activeFilter = getActiveFilter(); const search = document.getElementById('search').value.toLowerCase(); const list = document.getElementById('meals-list'); list.innerHTML = ''; MEALS.forEach((meal, i) => { if (!mealMatchesFilter(meal, activeFilter, search)) return; list.appendChild(createMealCard(meal, i)); }); updateTotal(); } function findMealCard(index) { return document.querySelector(`[data-meal-index="${index}"]`); } function updateMealCardQty(index) { const card = findMealCard(index); if (!card) return; const qty = quantities[index] || 0; card.classList.toggle('selected', qty > 0); const qtyInput = card.querySelector('.qty'); if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty; } function setQtyState(index, value) { const next = Math.max(0, parseInt(value, 10) || 0); if (next === 0) delete quantities[index]; else quantities[index] = next; } function setQty(index, value) { setQtyState(index, value); updateMealCardQty(index); updateTotal(); } function changeQty(index, delta) { const current = quantities[index] || 0; setQty(index, current + delta); } function bindMealsListDelegation() { if (mealsListDelegationBound) return; mealsListDelegationBound = true; const list = document.getElementById('meals-list'); list.addEventListener('click', (e) => { const btn = e.target.closest('[data-qty-delta]'); if (!btn) return; const card = btn.closest('[data-meal-index]'); if (!card) return; const index = parseInt(card.dataset.mealIndex, 10); const delta = parseInt(btn.dataset.qtyDelta, 10); changeQty(index, delta); }); list.addEventListener('input', (e) => { const input = e.target.closest('.qty'); if (!input) return; const card = input.closest('[data-meal-index]'); if (!card) return; const index = parseInt(card.dataset.mealIndex, 10); setQtyState(index, input.value); updateTotal(); }); list.addEventListener('focusout', (e) => { const input = e.target.closest('.qty'); if (!input) return; const card = input.closest('[data-meal-index]'); if (!card) return; updateMealCardQty(parseInt(card.dataset.mealIndex, 10)); }); } function updateTotal() { let total = 0; let count = 0; Object.entries(quantities).forEach(([i, qty]) => { total += employeePrice(MEALS[i].price || 0) * qty; count += qty; }); document.getElementById('total-display').textContent = '$' + total.toFixed(2); document.getElementById('count-display').textContent = count + ' meal' + (count !== 1 ? 's' : ''); const submitBtn = document.getElementById('submit-btn'); submitBtn.disabled = count === 0 || (CONFIG.authMode === 'google' && !googleCredential); } async function submitOrder() { 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) { showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' }); return; } payload = { google_id_token: googleCredential, items: buildItems() }; successName = googleUser.name; } else { 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; } const btn = document.getElementById('submit-btn'); btn.disabled = true; btn.textContent = 'Submitting...'; try { const res = await fetch(SUBMIT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (res.ok) { for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.classList.add('is-hidden'); } document.getElementById('success').classList.remove('is-hidden'); document.getElementById('success-detail').textContent = successName + ' — $' + (data.total || 0).toFixed(2) + ' total. You\'re all set!'; 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 { showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' }); btn.disabled = false; btn.textContent = 'Submit Order'; } } catch (e) { 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() { let employees = ROSTER; try { const res = await fetch(ROSTER_URL); const data = await res.json(); const candidate = data.employees; if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) { employees = candidate; } else { employees = ROSTER; } } catch (e) {} const select = document.getElementById('emp-name'); populateEmpSelect(select, employees); } function showClosedOverlay() { const overlay = document.getElementById('closed-overlay'); overlay.classList.add('is-visible'); overlay.style.display = 'flex'; document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; }); const submitBtn = document.getElementById('submit-btn'); if (submitBtn) submitBtn.disabled = true; if (formStatusData) startCountdown(formStatusData); } function adminBypassClosed() { const overlay = document.getElementById('closed-overlay'); overlay.classList.remove('is-visible'); overlay.style.display = 'none'; document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = false; }); const submitBtn = document.getElementById('submit-btn'); if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0; if (!document.getElementById('admin-closed-banner')) { const banner = document.createElement('div'); banner.id = 'admin-closed-banner'; banner.className = 'closed-banner'; banner.textContent = 'Form is closed — admin override active'; const deadline = document.querySelector('.deadline'); if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling); } document.querySelectorAll('[data-meal-index]').forEach(card => { const index = parseInt(card.dataset.mealIndex, 10); const meal = MEALS[index]; if (meal) updateMealCard(card, meal, index); }); updateTotal(); } function checkAdmin() { if (!ADMIN_URL) return Promise.resolve(); const headers = {}; if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential; return fetch(ADMIN_URL, { headers }) .then(r => r.ok ? r.json() : null) .then(data => { if (data && data.weeks) { isAdmin = true; window.isAdmin = true; adminWeeks = data.weeks; window.adminWeeks = adminWeeks; const ab = document.getElementById('admin-btn'); if (ab) ab.classList.remove('is-hidden'); if (formClosed) adminBypassClosed(); } else if (formClosed) { showClosedOverlay(); } }) .catch(() => { if (formClosed) showClosedOverlay(); }); } function showAdmin() { document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { if (el.id !== 'admin-panel') { el.dataset.prevHidden = el.classList.contains('is-hidden') ? '1' : ''; el.classList.add('is-hidden'); } }); document.getElementById('admin-panel').classList.remove('is-hidden'); const footer = document.querySelector('.sticky-footer'); if (footer) footer.classList.add('is-hidden'); const select = document.getElementById('admin-week'); select.innerHTML = ''; adminWeeks.forEach(w => { const opt = document.createElement('option'); opt.value = w.week; opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : ''); select.appendChild(opt); }); if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') { loadWeekOrders(adminWeeks[0].week); } } function hideAdmin() { document.getElementById('admin-panel').classList.add('is-hidden'); document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { if (el.dataset.prevHidden === '1') el.classList.add('is-hidden'); else el.classList.remove('is-hidden'); delete el.dataset.prevHidden; }); const footer = document.querySelector('.sticky-footer'); if (footer) footer.classList.remove('is-hidden'); } const easternFormatter = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }); function getEasternParts(date) { const values = {}; easternFormatter.formatToParts(date).forEach(part => { if (part.type !== 'literal') values[part.type] = Number(part.value); }); return values; } function getEasternOffsetMs(date) { const parts = getEasternParts(date); return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime(); } function startCountdown(data) { if (data && data.reopen_at) reopenAtMs = data.reopen_at * 1000; updateCountdown(); if (countdownTimer !== null) clearInterval(countdownTimer); countdownTimer = setInterval(updateCountdown, 1000); } function updateCountdown() { const now = new Date(); let target; if (reopenAtMs) { target = new Date(reopenAtMs); } else { const etNow = getEasternParts(now); const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay(); const hour = etNow.hour; let daysUntil = (1 - day + 7) % 7; if (daysUntil === 0 && hour >= 8) daysUntil = 7; const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0); target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs))); } let diff = Math.max(0, Math.floor((target - now) / 1000)); const d = Math.floor(diff / 86400); diff %= 86400; const h = Math.floor(diff / 3600); diff %= 3600; const m = Math.floor(diff / 60); const s = diff % 60; const pad = n => String(n).padStart(2, '0'); document.getElementById('countdown').textContent = d + 'd ' + pad(h) + 'h ' + pad(m) + 'm ' + pad(s) + 's'; } async function checkFormStatus() { if (!STATUS_URL) return; try { const res = await fetch(STATUS_URL); const data = await res.json(); if (data.status === 'closed') { formClosed = true; formStatusData = data; if (isAdmin) { adminBypassClosed(); } else if (CONFIG.authMode !== 'google' || googleCredential) { showClosedOverlay(); } } } catch (e) {} } function init() { checkFormStatus(); if (CONFIG.authMode === 'google') { waitForGoogleAuth(); } else { loadRoster(); checkDuplicateOrder(); } const tags = new Set(); MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t))); const cats = ['All Meals', ...Array.from(tags).sort()]; const filtersEl = document.getElementById('filters'); cats.forEach(cat => { const btn = document.createElement('button'); btn.type = 'button'; 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'); b.setAttribute('aria-pressed', 'false'); }); btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true'); renderMeals(); }); filtersEl.appendChild(btn); }); document.getElementById('search').addEventListener('input', renderMeals); document.getElementById('submit-btn').addEventListener('click', submitOrder); const adminBtn = document.getElementById('admin-btn'); if (adminBtn) adminBtn.addEventListener('click', showAdmin); const signOutBtn = document.getElementById('sign-out-btn'); if (signOutBtn) signOutBtn.addEventListener('click', signOut); const successBackBtn = document.getElementById('success-back-btn'); if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload()); bindMealsListDelegation(); renderMeals(); } window.showAdmin = showAdmin; window.hideAdmin = hideAdmin; window.submitOrder = submitOrder; window.changeQty = changeQty; init();