2026-05-12 18:25:15 -04:00
"""
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
2026-05-13 11:36:14 -04:00
import boto3
2026-05-12 18:25:15 -04:00
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 )
2026-05-12 19:31:27 -04:00
def generate_form (
2026-05-13 11:36:14 -04:00
menu : dict ,
config : dict ,
api_url : str = " " ,
api_key : str = " " ,
bulk_discount : float = 0 ,
company_subsidy : float = 0 ,
google_client_id : str = " " ,
2026-05-12 19:31:27 -04:00
) - > str :
2026-05-12 18:25:15 -04:00
meals_json = json . dumps ( menu [ " meals " ] )
roster_json = json . dumps ( config . get ( " roster " , [ ] ) )
2026-05-12 20:16:46 -04:00
deadline = config . get ( " order_deadline " , " Thursday 11:59 PM " )
2026-05-12 18:25:15 -04:00
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 "
2026-05-13 11:36:14 -04:00
status_url = f " { api_url } /api/form-status/ { week } " if api_url else f " /api/form-status/ { week } "
2026-05-12 20:16:46 -04:00
roster_url = f " { api_url } /api/roster " if api_url else " /api/roster "
2026-05-12 18:25:15 -04:00
api_key_json = json . dumps ( api_key )
2026-05-13 11:36:14 -04:00
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 )
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 payload = JSON . parse ( atob ( response . credential . split ( ' . ' ) [ 1 ] ) ) ;
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 ( ) ;
}
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 ( ! 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 . 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 ' ;
}
}
"""
else :
submit_order_js = """
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 ,
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 . 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 ' ;
}
}
"""
2026-05-12 18:25:15 -04:00
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 : 800 px ; margin : 0 auto ; padding : 16 px ; } }
header { { background : #1a1a2e; color: #fff; padding: 24px 16px; text-align: center; margin-bottom: 24px; border-radius: 12px; }}
header h1 { { font - size : 1.5 rem ; margin - bottom : 4 px ; } }
header p { { font - size : 0.85 rem ; 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 : 4 px ; font - size : 0.85 rem ; } }
. employee - info input , . employee - info select { { width : 100 % ; padding : 10 px 12 px ; border : 1 px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 12px; }}
. filters { { display : flex ; flex - wrap : wrap ; gap : 8 px ; margin - bottom : 16 px ; } }
. filters button { { padding : 6 px 14 px ; border : 1 px 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 : 80 px ; height : 80 px ; border - radius : 8 px ; object - fit : cover ; flex - shrink : 0 ; } }
. meal - info { { flex : 1 ; min - width : 0 ; } }
. meal - name { { font - weight : 600 ; font - size : 0.95 rem ; margin - bottom : 2 px ; } }
. meal - meta { { font - size : 0.8 rem ; color : #6b7280; margin-bottom: 4px; }}
. meal - tags { { display : flex ; flex - wrap : wrap ; gap : 4 px ; margin - bottom : 4 px ; } }
. meal - tags span { { font - size : 0.7 rem ; padding : 2 px 8 px ; border - radius : 10 px ; 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 : 32 px ; height : 32 px ; border : 1 px 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 : 8 px 0 0 8 px ; } }
. qty - control button : last - child { { border - radius : 0 8 px 8 px 0 ; } }
. qty - control . qty { { width : 36 px ; height : 32 px ; text - align : center ; border : 1 px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.95rem; font-weight: 600; }}
. meal - card . selected { { border : 2 px 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 : 800 px ; margin : 0 auto ; display : flex ; justify - content : space - between ; align - items : center ; } }
. total { { font - size : 1.1 rem ; 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 : 60 px 20 px ; } }
. success - msg h2 { { color : #15803d; margin-bottom: 8px; }}
. meal - desc { { font - size : 0.8 rem ; color : #6b7280; margin-bottom: 4px; }}
. search - bar { { width : 100 % ; padding : 10 px 12 px ; border : 1 px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
body { { padding - bottom : 80 px ; } }
. closed - banner { { background : #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }}
2026-05-13 11:36:14 -04:00
@media ( min - width : 768 px ) { {
. container { { max - width : 1100 px ; } }
#meals-list {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }}
#meals-list .meal-card {{ margin-bottom: 0; }}
} }
@media ( min - width : 1200 px ) { {
. container { { max - width : 1400 px ; } }
#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.5 rem ; margin - bottom : 4 px ; color : #1d1d1f; }}
. auth - card p { { font - size : 0.9 rem ; color : #6b7280; margin-bottom: 32px; }}
. auth - card . logo { { font - size : 2 rem ; margin - bottom : 16 px ; } }
. 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.5 rem ; margin - bottom : 8 px ; color : #1d1d1f; }}
. closed - card p { { font - size : 0.9 rem ; color : #6b7280; margin-bottom: 24px; }}
. closed - card . logo { { font - size : 2.5 rem ; margin - bottom : 16 px ; } }
. countdown { { font - size : 2 rem ; font - weight : 700 ; color : #1a1a2e; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }}
. countdown - label { { font - size : 0.75 rem ; color : #9ca3af; margin-top: 4px; }}
2026-05-12 18:25:15 -04:00
< / style >
2026-05-13 11:36:14 -04:00
{ ' <script src= " https://accounts.google.com/gsi/client " async defer></script> ' if use_google_auth else ' ' }
2026-05-12 18:25:15 -04:00
< / head >
< body >
2026-05-13 11:36:14 -04:00
< 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 " >🍽</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 ' ' } >
2026-05-12 18:25:15 -04:00
< 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 >
2026-05-13 11:36:14 -04:00
{ ' <div class= " discount-banner " >Prices reflect employee cost after ' + ( f " { int ( bulk_discount ) } % bulk discount " if bulk_discount > 0 else " " ) + ( " + " if bulk_discount > 0 and company_subsidy > 0 else " " ) + ( f " { int ( company_subsidy ) } % company subsidy " if company_subsidy > 0 else " " ) + " </div> " if has_discount else " " }
2026-05-12 18:25:15 -04:00
2026-05-13 11:36:14 -04:00
{ auth_section_html }
2026-05-12 18:25:15 -04:00
< input type = " text " class = " search-bar " id = " search " placeholder = " Search meals... " >
< div class = " filters " id = " filters " > < / div >
< div id = " meals-list " > < / div >
2026-05-13 11:36:14 -04:00
< div class = " sticky-footer " { ' style= " display:none; " id= " sticky-footer " ' if use_google_auth else ' ' } >
2026-05-12 18:25:15 -04:00
< 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} ' ;
2026-05-12 20:16:46 -04:00
const ROSTER_URL = ' {roster_url} ' ;
2026-05-12 18:25:15 -04:00
const API_KEY = { api_key_json } ;
const WEEK = ' {week} ' ;
2026-05-13 11:36:14 -04:00
const BULK_DISCOUNT = { bulk_discount } ;
const COMPANY_SUBSIDY = { company_subsidy } ;
2026-05-12 18:25:15 -04:00
const quantities = { { } } ;
let formClosed = false ;
2026-05-13 11:36:14 -04:00
{ ' const GOOGLE_CLIENT_ID = ' + google_client_id_json + ' ; \n let googleCredential = null; \n let googleUser = null; ' if use_google_auth else ' ' }
2026-05-12 18:25:15 -04:00
2026-05-13 11:36:14 -04:00
function employeePrice ( retail ) { {
if ( ! retail ) return 0 ;
const bulk = retail * ( 1 - BULK_DISCOUNT / 100 ) ;
return Math . round ( bulk * ( 1 - COMPANY_SUBSIDY / 100 ) * 100 ) / 100 ;
} }
{ google_auth_js }
2026-05-12 18:25:15 -04:00
function init ( ) { {
checkFormStatus ( ) ;
2026-05-13 11:36:14 -04:00
{ ' waitForGoogleAuth(); ' if use_google_auth else ' loadRoster(); ' }
2026-05-12 18:25:15 -04:00
/ / 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 > ` : ' ' ;
2026-05-13 11:36:14 -04:00
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 ) } } ` : ' — ' ) ;
2026-05-12 18:25:15 -04:00
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 } }
2026-05-13 11:36:14 -04:00
< div class = " meal-meta " > $ { { priceHtml } } & middot ; $ { { meal . calories | | ' ? ' } } cal & middot ; $ { { meal . protein | | ' ? ' } } protein < / div >
2026-05-12 18:25:15 -04:00
< 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 ] ) = > { {
2026-05-13 11:36:14 -04:00
total + = employeePrice ( MEALS [ i ] . price | | 0 ) * qty ;
2026-05-12 18:25:15 -04:00
count + = qty ;
} } ) ;
document . getElementById ( ' total-display ' ) . textContent = ` $ $ { { total . toFixed ( 2 ) } } ` ;
document . getElementById ( ' count-display ' ) . textContent = ` $ { { count } } meal $ { { count != = 1 ? ' s ' : ' ' } } ` ;
2026-05-13 11:36:14 -04:00
document . getElementById ( ' submit-btn ' ) . disabled = count == = 0 { ' || !googleCredential ' if use_google_auth else ' ' } ;
2026-05-12 18:25:15 -04:00
} }
2026-05-13 11:36:14 -04:00
{ submit_order_js }
2026-05-12 18:25:15 -04:00
2026-05-12 20:16:46 -04:00
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 ) ;
} } ) ;
} }
} }
2026-05-12 18:25:15 -04:00
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 ;
2026-05-13 11:36:14 -04:00
document . getElementById ( ' closed-overlay ' ) . style . display = ' flex ' ;
startCountdown ( ) ;
2026-05-12 18:25:15 -04:00
} }
} } catch ( e ) { { } }
} }
2026-05-13 11:36:14 -04:00
function startCountdown ( ) { {
updateCountdown ( ) ;
setInterval ( updateCountdown , 1000 ) ;
} }
function updateCountdown ( ) { {
const now = new Date ( ) ;
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 ;
const target = new Date ( etDate ) ;
target . setDate ( target . getDate ( ) + daysUntil ) ;
target . setHours ( 8 , 0 , 0 , 0 ) ;
let diff = Math . max ( 0 , Math . floor ( ( target - etDate ) / 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 ` ;
} }
2026-05-12 18:25:15 -04:00
init ( ) ;
< / script >
< / body >
< / html > """
def main ( ) :
parser = argparse . ArgumentParser ( description = " Generate meal order form HTML " )
2026-05-12 19:31:27 -04:00
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) "
)
2026-05-13 11:36:14 -04:00
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 " ,
)
2026-05-12 18:25:15 -04:00
args = parser . parse_args ( )
config = load_config ( )
menu = latest_menu ( )
2026-05-13 11:36:14 -04:00
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 :
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 ,
)
2026-05-12 18:25:15 -04:00
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 " :
2026-05-12 19:31:27 -04:00
print ( " Start the server with: python3 src/server/app.py " )
2026-05-12 18:25:15 -04:00
else :
print ( f " API endpoint: { args . api_url } " )
if __name__ == " __main__ " :
main ( )