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