meal-order-manager/src/server/generate_form.py
Adam Moussa fa9ac6974e Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
  bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
2026-05-13 14:05:10 -04:00

687 lines
28 KiB
Python

"""
Generates a self-contained HTML order form from the latest scraped menu.
The form shows this week's meals with quantity selectors, a running total,
and submits orders to the backend (local Flask or cloud API Gateway).
Usage:
python3 generate_form.py # local mode
python3 generate_form.py --api-url URL --api-key KEY # cloud mode
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_DIR = PROJECT_ROOT / "output"
CONFIG_PATH = PROJECT_ROOT / "config.json"
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def latest_menu() -> dict:
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr)
sys.exit(1)
with open(files[0]) as f:
return json.load(f)
def generate_form(
menu: dict,
config: dict,
api_url: str = "",
api_key: str = "",
bulk_discount: float = 0,
company_subsidy: float = 0,
google_client_id: str = "",
) -> str:
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
deadline = config.get("order_deadline", "Thursday 11:59 PM")
week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown")
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
status_url = (
f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
)
roster_url = f"{api_url}/api/roster" if api_url else "/api/roster"
api_key_json = json.dumps(api_key).replace("</", "<\\/")
has_discount = bulk_discount > 0 or company_subsidy > 0
use_google_auth = bool(google_client_id)
google_client_id_json = json.dumps(google_client_id).replace("</", "<\\/")
if use_google_auth:
auth_section_html = """ <div class="employee-info">
<div id="user-info">
<div style="display:flex;align-items:center;gap:12px;">
<img id="user-avatar" style="width:40px;height:40px;border-radius:50%;object-fit:cover;display:none;" alt="">
<div style="flex:1;">
<div id="user-name" style="font-weight:600;font-size:0.95rem;"></div>
<div id="user-email" style="font-size:0.85rem;color:#6b7280;"></div>
</div>
<button onclick="signOut()" style="background:none;border:1px solid #d1d5db;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;color:#6b7280;">Sign out</button>
</div>
</div>
</div>"""
else:
auth_section_html = """ <div class="employee-info">
<label for="emp-name">Your Name</label>
<select id="emp-name"><option value="">Loading...</option></select>
<input type="hidden" id="emp-email">
</div>"""
if use_google_auth:
google_auth_js = """
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,
hosted_domain: 'seahavenind.com',
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;
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(b64));
googleUser = { name: payload.name, email: payload.email };
document.getElementById('auth-overlay').style.display = 'none';
document.getElementById('app').style.display = 'block';
var footer = document.getElementById('sticky-footer');
if (footer) footer.style.display = 'block';
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.style.display = 'block';
}
updateTotal();
checkDuplicateOrder();
}
function signOut() {
googleCredential = null;
googleUser = null;
google.accounts.id.disableAutoSelect();
document.getElementById('auth-overlay').style.display = 'flex';
document.getElementById('app').style.display = 'none';
var footer = document.getElementById('sticky-footer');
if (footer) footer.style.display = 'none';
}
"""
else:
google_auth_js = ""
if use_google_auth:
submit_order_js = """
async function submitOrder() {
if (formClosed) { alert('Orders are closed.'); return; }
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
const items = Object.entries(quantities).map(([i, qty]) => ({
name: MEALS[i].name,
retail_price: MEALS[i].price,
quantity: qty,
}));
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({ google_id_token: googleCredential, items }),
});
const data = await res.json();
if (res.ok) {
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
document.getElementById('success').style.display = 'block';
document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
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';
}
}
"""
else:
submit_order_js = """
async function submitOrder() {
if (formClosed) { alert('Orders are closed.'); return; }
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 items = Object.entries(quantities).map(([i, qty]) => ({
name: MEALS[i].name,
retail_price: MEALS[i].price,
quantity: qty,
}));
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({ employee_name: name, employee_email: email, items }),
});
const data = await res.json();
if (res.ok) {
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
document.getElementById('success').style.display = 'block';
document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
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';
}
}
"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sea Haven — Meal Order ({week})</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
.container {{ max-width: 800px; margin: 0 auto; padding: 16px; }}
header {{ background: #1a1a2e; color: #fff; padding: 24px 16px; text-align: center; margin-bottom: 24px; border-radius: 12px; }}
header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
header p {{ font-size: 0.85rem; opacity: 0.8; }}
.deadline {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; }}
.employee-info {{ background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.employee-info label {{ display: block; font-weight: 600; margin-bottom: 4px; font-size: 0.85rem; }}
.employee-info input, .employee-info select {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 12px; }}
.filters {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }}
.filters button {{ padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 20px; background: #fff; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }}
.filters button.active {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
.meal-card {{ background: #fff; border-radius: 12px; padding: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; gap: 12px; align-items: center; }}
.meal-img {{ width: 80px; height: 80px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }}
.meal-info {{ flex: 1; min-width: 0; }}
.meal-name {{ font-weight: 600; font-size: 0.95rem; margin-bottom: 2px; }}
.meal-meta {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
.meal-tags {{ display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }}
.meal-tags span {{ font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-weight: 500; }}
.meal-tags span.new {{ background: #dcfce7; color: #15803d; }}
.qty-control {{ display: flex; align-items: center; gap: 0; flex-shrink: 0; }}
.qty-control button {{ width: 32px; height: 32px; border: 1px solid #d1d5db; background: #f9fafb; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }}
.qty-control button:first-child {{ border-radius: 8px 0 0 8px; }}
.qty-control button:last-child {{ border-radius: 0 8px 8px 0; }}
.qty-control .qty {{ width: 36px; height: 32px; text-align: center; border: 1px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.95rem; font-weight: 600; }}
.meal-card.selected {{ border: 2px solid #1a1a2e; }}
.sticky-footer {{ position: fixed; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid #e5e7eb; padding: 12px 16px; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); z-index: 100; }}
.sticky-footer .inner {{ max-width: 800px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }}
.total {{ font-size: 1.1rem; font-weight: 700; }}
.submit-btn {{ background: #1a1a2e; color: #fff; border: none; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }}
.submit-btn:disabled {{ opacity: 0.4; cursor: not-allowed; }}
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
.success-msg {{ text-align: center; padding: 60px 20px; }}
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
body {{ padding-bottom: 80px; }}
.closed-banner {{ background: #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }}
@media (min-width: 768px) {{
.container {{ max-width: 1100px; }}
#meals-list {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }}
#meals-list .meal-card {{ margin-bottom: 0; }}
}}
@media (min-width: 1200px) {{
.container {{ max-width: 1400px; }}
#meals-list {{ grid-template-columns: repeat(3, 1fr); }}
}}
.discount-banner {{ background: #dcfce7; color: #15803d; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.85rem; text-align: center; font-weight: 500; }}
.price-retail {{ text-decoration: line-through; color: #9ca3af; font-size: 0.75rem; margin-right: 4px; }}
.price-employee {{ color: #15803d; font-weight: 600; }}
.auth-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; z-index: 1000; }}
.auth-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 400px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
.auth-card h1 {{ font-size: 1.5rem; margin-bottom: 4px; color: #1d1d1f; }}
.auth-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 32px; }}
.auth-card .logo {{ font-size: 2rem; margin-bottom: 16px; }}
.closed-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: none; align-items: center; justify-content: center; z-index: 2000; }}
.closed-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
.closed-card h1 {{ font-size: 1.5rem; margin-bottom: 8px; color: #1d1d1f; }}
.closed-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 24px; }}
.closed-card .logo {{ font-size: 2.5rem; margin-bottom: 16px; }}
.countdown {{ font-size: 2rem; font-weight: 700; color: #1a1a2e; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }}
.countdown-label {{ font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }}
</style>
{
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
if use_google_auth
else ""
}
</head>
<body>
<div class="closed-overlay" id="closed-overlay">
<div class="closed-card">
<div class="logo">&#127869;</div>
<h1>Orders Are Closed</h1>
<p>Orders open again Monday at 8:00 AM ET</p>
<div class="countdown" id="countdown"></div>
<div class="countdown-label">until orders open</div>
</div>
</div>
{
'<div class="auth-overlay" id="auth-overlay"><div class="auth-card"><div class="logo">&#127869;</div><h1>Sea Haven Meal Order</h1><p>Sign in with your company Google account to place your order.</p><div id="g-signin-btn" style="display:flex;justify-content:center;"></div></div></div>'
if use_google_auth
else ""
}
<div class="container" id="app" {'style="display:none;"' if use_google_auth else ""}>
<header>
<h1>Sea Haven Meal Order</h1>
<p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p>
</header>
<div class="deadline">Order deadline: {deadline}</div>
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
{
'<div class="discount-banner">Prices reflect employee cost after '
+ (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "")
+ (" + " if bulk_discount > 0 and company_subsidy > 0 else "")
+ (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "")
+ "</div>"
if has_discount
else ""
}
{auth_section_html}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
<div class="filters" id="filters"></div>
<div id="meals-list"></div>
<div class="sticky-footer" {
'style="display:none;" id="sticky-footer"' if use_google_auth else ""
}>
<div class="inner">
<div>
<span class="total" id="total-display">$0.00</span>
<span style="font-size:0.8rem;color:#6b7280;margin-left:8px;" id="count-display">0 meals</span>
</div>
<button class="submit-btn" id="submit-btn" disabled>Submit Order</button>
</div>
</div>
<div id="success" class="success-msg" style="display:none;">
<h2>Order submitted!</h2>
<p id="success-detail"></p>
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
</div>
</div>
<script>
const MEALS = {meals_json};
const ROSTER = {roster_json};
const SUBMIT_URL = '{submit_url}';
const STATUS_URL = '{status_url}';
const ROSTER_URL = '{roster_url}';
const API_KEY = {api_key_json};
const WEEK = '{week}';
const BULK_DISCOUNT = {bulk_discount};
const COMPANY_SUBSIDY = {company_subsidy};
const quantities = {{}};
let formClosed = false;
{
"const GOOGLE_CLIENT_ID = "
+ google_client_id_json
+ ";\nlet googleCredential = null;\nlet googleUser = null;"
if use_google_auth
else ""
}
function escapeHtml(str) {{
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.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;
}}
{google_auth_js}
function checkDuplicateOrder() {{
try {{
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
document.getElementById('duplicate-warning').style.display = 'block';
}}
}} catch (e) {{}}
}}
function init() {{
checkFormStatus();
{
"waitForGoogleAuth();"
if use_google_auth
else "loadRoster(); checkDuplicateOrder();"
}
// Build filter buttons
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.textContent = cat;
if (cat === 'All Meals') btn.classList.add('active');
btn.onclick = () => {{
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);
renderMeals();
}}
function renderMeals() {{
const activeFilter = document.querySelector('.filters button.active')?.textContent || 'All Meals';
const search = document.getElementById('search').value.toLowerCase();
const list = document.getElementById('meals-list');
list.innerHTML = '';
MEALS.forEach((meal, i) => {{
if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return;
if (search && !meal.name.toLowerCase().includes(search)) return;
const qty = quantities[i] || 0;
const card = document.createElement('div');
card.className = 'meal-card' + (qty > 0 ? ' selected' : '');
let tagsHtml = '';
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
const empPrice = employeePrice(meal.price);
const priceHtml = hasDiscount
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
const safeName = escapeHtml(meal.name);
const safeImageUrl = escapeHtml(meal.image_url);
card.innerHTML = `
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
<div class="meal-info">
<div class="meal-name">${{safeName}}</div>
${{descHtml}}
<div class="meal-meta">${{priceHtml}} &middot; ${{escapeHtml(meal.calories || '?')}} cal &middot; ${{escapeHtml(meal.protein || '?')}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>&minus;</button>
<input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
</div>
`;
list.appendChild(card);
}});
updateTotal();
}}
function changeQty(index, delta) {{
const current = quantities[index] || 0;
const next = Math.max(0, current + delta);
if (next === 0) delete quantities[index]; else quantities[index] = next;
renderMeals();
}}
function updateTotal() {{
let total = 0, 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' : ''}}`;
document.getElementById('submit-btn').disabled = count === 0{
" || !googleCredential" if use_google_auth else ""
};
}}
{submit_order_js}
{
""
if use_google_auth
else '''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);
}});
}}
}}
'''
}
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;
document.getElementById('closed-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;
startCountdown(data);
}}
}} catch (e) {{}}
}}
let reopenAtMs = null;
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 etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
const etDate = new Date(etStr);
const day = etDate.getDay();
const hour = etDate.getHours();
let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
target = new Date(etDate);
target.setDate(target.getDate() + daysUntil);
target.setHours(8, 0, 0, 0);
}}
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`;
}}
init();
</script>
</body>
</html>"""
def main():
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
parser.add_argument(
"--api-url", default="", help="API Gateway base URL (cloud mode)"
)
parser.add_argument(
"--api-key", default="", help="API key for order submission (cloud mode)"
)
parser.add_argument(
"--bulk-discount",
type=float,
default=None,
help="Bulk discount percentage (e.g., 10 for 10%% off)",
)
parser.add_argument(
"--company-subsidy",
type=float,
default=None,
help="Company subsidy percentage (e.g., 50 for 50%% off after bulk discount)",
)
parser.add_argument(
"--google-client-id",
default=None,
help="Google OAuth Client ID for Sign-In authentication",
)
args = parser.parse_args()
config = load_config()
menu = latest_menu()
bulk_discount = (
args.bulk_discount
if args.bulk_discount is not None
else config.get("bulk_discount_percent", 0)
)
company_subsidy = (
args.company_subsidy
if args.company_subsidy is not None
else config.get("company_subsidy_percent", 0)
)
google_client_id = args.google_client_id or config.get("google_client_id", "")
if not google_client_id:
try:
import boto3
ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
google_client_id = resp["Parameter"]["Value"]
except Exception:
pass
html = generate_form(
menu,
config,
api_url=args.api_url,
api_key=args.api_key,
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,
)
week = datetime.now().strftime("%Y-W%U")
output_file = OUTPUT_DIR / f"order-form-{week}.html"
with open(output_file, "w") as f:
f.write(html)
mode = "cloud" if args.api_url else "local"
print(f"Generated order form ({mode} mode): {output_file}")
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
if mode == "local":
print(" Start the server with: python3 src/server/app.py")
else:
print(f" API endpoint: {args.api_url}")
if __name__ == "__main__":
main()