mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
620 lines
20 KiB
JavaScript
620 lines
20 KiB
JavaScript
|
|
/* 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, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|