mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 10:03:13 +00:00
709 lines
28 KiB
Python
709 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">🍽</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">🍽</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} · 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, '&')
|
|
.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;
|
|
}}
|
|
{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}} · ${{escapeHtml(meal.calories || '?')}} cal · ${{escapeHtml(meal.protein || '?')}} protein</div>
|
|
<div class="meal-tags">${{tagsHtml}}</div>
|
|
</div>
|
|
<div class="qty-control">
|
|
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>−</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;
|
|
|
|
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`;
|
|
}}
|
|
|
|
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()
|