Update order form UI and CI workflow for new features

Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
This commit is contained in:
Adam Moussa 2026-05-13 11:36:14 -04:00
parent ef4726aa3f
commit f437ca8f0f
2 changed files with 347 additions and 67 deletions

View file

@ -72,11 +72,46 @@ jobs:
echo "::add-mask::$API_KEY"
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings
id: discount
run: |
RESULT=$(aws dynamodb get-item \
--table-name meal-order-manager-orders \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \
--output json 2>/dev/null || echo '{}')
BULK=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
SUBSIDY=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
- name: Get Google Client ID
id: google
run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text 2>/dev/null || echo "")
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
GOOGLE_CLIENT_ID=""
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
- name: Generate order form
run: |
python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \
--api-key "${{ steps.apikey.outputs.api_key }}"
--api-key "${{ steps.apikey.outputs.api_key }}" \
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }}
- name: Upload menu to DynamoDB
run: python3 scripts/upload_menu.py

View file

@ -15,6 +15,8 @@ import sys
from datetime import datetime
from pathlib import Path
import boto3
PROJECT_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_DIR = PROJECT_ROOT / "output"
CONFIG_PATH = PROJECT_ROOT / "config.json"
@ -35,7 +37,13 @@ def latest_menu() -> dict:
def generate_form(
menu: dict, config: dict, api_url: str = "", api_key: str = ""
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"])
roster_json = json.dumps(config.get("roster", []))
@ -43,9 +51,174 @@ def generate_form(
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 ""
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)
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';
}
}
"""
return f"""<!DOCTYPE html>
<html lang="en">
@ -93,23 +266,54 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }}
.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="container" id="app">
<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="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 ""}
<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>
{auth_section_html}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
@ -117,7 +321,7 @@ body {{ padding-bottom: 80px; }}
<div id="meals-list"></div>
<div class="sticky-footer">
<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>
@ -141,12 +345,21 @@ 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 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}
function init() {{
checkFormStatus();
loadRoster();
{'waitForGoogleAuth();' if use_google_auth else 'loadRoster();'}
// Build filter buttons
const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
@ -189,14 +402,18 @@ function renderMeals() {{
(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)}}` : '—';
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)}}` : '—');
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-meta">${{priceHtml}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
@ -221,56 +438,15 @@ function changeQty(index, delta) {{
function updateTotal() {{
let total = 0, count = 0;
Object.entries(quantities).forEach(([i, qty]) => {{
total += (MEALS[i].price || 0) * 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;
document.getElementById('submit-btn').disabled = count === 0{' || !googleCredential' if use_google_auth else ''};
}}
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';
}}
}}
{submit_order_js}
async function loadRoster() {{
try {{
@ -309,18 +485,42 @@ async function checkFormStatus() {{
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);
document.getElementById('closed-overlay').style.display = 'flex';
startCountdown();
}}
}} catch (e) {{}}
}}
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`;
}}
init();
</script>
</body>
@ -335,11 +535,56 @@ def main():
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()
html = generate_form(menu, config, api_url=args.api_url, api_key=args.api_key)
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,
)
week = datetime.now().strftime("%Y-W%U")
output_file = OUTPUT_DIR / f"order-form-{week}.html"