diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index 3c03083..214fbf0 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -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 diff --git a/src/server/generate_form.py b/src/server/generate_form.py index e601e08..4d28a65 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -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 = """
+
+
+ +
+
+
+
+ +
+
+
""" + else: + auth_section_html = """
+ + + +
""" + + 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""" @@ -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; }} +{'' if use_google_auth else ''} - -
+
+
+ +

Orders Are Closed

+

Orders open again Monday at 8:00 AM ET

+
+
until orders open
+
+
+{'

Sea Haven Meal Order

Sign in with your company Google account to place your order.

' if use_google_auth else ''} +

Sea Haven Meal Order

Week of {week} · Menu scraped {scraped_at[:10]}

Order deadline: {deadline}
+ {'
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 "") + "
" if has_discount else ""} -
- - - -
+{auth_section_html} @@ -117,7 +321,7 @@ body {{ padding-bottom: 80px; }}
-