meal-order-manager/src/server/templates/form.js

620 lines
20 KiB
JavaScript
Raw Normal View History

/* 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 API_KEY = CONFIG.apiKey;
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 mealsListDelegationBound = false;
window.googleCredential = null;
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
}
window.FormUtils = { escapeHtml, employeePrice };
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() {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
initGoogleAuth();
} else {
setTimeout(waitForGoogleAuth, 50);
}
}
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();
}
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 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');
if (meal.description) {
descEl.textContent = meal.description;
descEl.classList.remove('is-hidden');
} 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;
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.value = qty;
}
function setQty(index, value) {
const next = Math.max(0, parseInt(value, 10) || 0);
if (next === 0) delete quantities[index];
else quantities[index] = next;
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('change', (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);
setQty(index, input.value);
});
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);
setQty(index, input.value);
});
}
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() {
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
let payload;
let successName;
if (CONFIG.authMode === 'google') {
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); 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; }
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 headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['x-api-key'] = API_KEY;
const res = await fetch(SUBMIT_URL, {
method: 'POST',
headers,
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) {}
} else {
alert(data.error || 'Something went wrong.');
btn.disabled = false;
btn.textContent = 'Submit Order';
}
} catch (e) {
alert('Failed to submit. Check your connection and try again.');
btn.disabled = false;
btn.textContent = 'Submit Order';
}
}
async function loadRoster() {
try {
const res = await fetch(ROSTER_URL);
const data = await res.json();
const select = document.getElementById('emp-name');
select.innerHTML = '<option value="">Select your name</option>';
(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 || '';
});
} catch (e) {
const select = document.getElementById('emp-name');
select.innerHTML = '<option value="">Select your name</option>';
ROSTER.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
select.appendChild(opt);
});
}
}
function showClosedOverlay() {
const overlay = document.getElementById('closed-overlay');
overlay.classList.add('is-visible');
overlay.style.display = 'flex';
document.querySelectorAll('.qty-control button').forEach(b => { b.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;
const headers = {};
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential;
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();
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') {
} else {
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';
btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : '');
btn.textContent = cat;
btn.addEventListener('click', () => {
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
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();