meal-order-manager/src/server/generate_form.py
Adam Moussa d332affd56
Initial commit: meal ordering automation system (#1)
Playwright-based menu scraper for Redefine Meals, self-contained HTML
order form with S3/CloudFront hosting, DynamoDB-backed order submission
via API Gateway, and automated payroll deduction reports via SES.
2026-05-12 18:25:15 -04:00

321 lines
13 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 = "") -> str:
meals_json = json.dumps(menu["meals"])
roster_json = json.dumps(config.get("roster", []))
deadline = config.get("order_deadline", "Thursday 6:00 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 ""
api_key_json = json.dumps(api_key)
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>
<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; }}
.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; }}
</style>
</head>
<body>
<div class="container" id="app">
<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="employee-info">
<label for="emp-name">Your Name</label>
<input type="text" id="emp-name" placeholder="Full name" autocomplete="name">
<label for="emp-email">Your Email</label>
<input type="email" id="emp-email" placeholder="you@seahavenind.com" autocomplete="email">
</div>
<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">
<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>
</div>
</div>
<script>
const MEALS = {meals_json};
const ROSTER = {roster_json};
const SUBMIT_URL = '{submit_url}';
const STATUS_URL = '{status_url}';
const API_KEY = {api_key_json};
const WEEK = '{week}';
const quantities = {{}};
let formClosed = false;
function init() {{
checkFormStatus();
// 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>${{t}}</span>`; }});
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
const price = meal.price ? `$${{meal.price.toFixed(2)}}` : '—';
card.innerHTML = `
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
<div class="meal-info">
<div class="meal-name">${{meal.name}}</div>
${{descHtml}}
<div class="meal-meta">${{price}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
<button onclick="changeQty(${{i}}, -1)">&minus;</button>
<input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)">+</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 += (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;
}}
async function submitOrder() {{
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,
price: MEALS[i].price,
quantity: qty,
subtotal: (MEALS[i].price || 0) * 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) {{
document.getElementById('app').querySelectorAll(':not(#success)').forEach(el => el.style.display = 'none');
document.getElementById('success').style.display = 'block';
document.getElementById('success-detail').textContent = `${{name}} — $${{data.total.toFixed(2)}} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
}} 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 checkFormStatus() {{
if (!STATUS_URL) return;
try {{
const res = await fetch(STATUS_URL);
const data = await res.json();
if (data.status === 'closed') {{
formClosed = true;
const banner = document.createElement('div');
banner.className = 'closed-banner';
banner.textContent = 'Orders are closed for this week.';
const deadline = document.querySelector('.deadline');
if (deadline) deadline.replaceWith(banner);
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').textContent = 'Closed';
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
}}
}} catch (e) {{}}
}}
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)")
args = parser.parse_args()
config = load_config()
menu = latest_menu()
html = generate_form(menu, config, api_url=args.api_url, api_key=args.api_key)
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(f" Start the server with: python3 src/server/app.py")
else:
print(f" API endpoint: {args.api_url}")
if __name__ == "__main__":
main()