From 216618a862e26792ab1cc940e333dd95e15cae55 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:19:51 -0400 Subject: [PATCH] refactor(form): extract Jinja templates and lock form JS in CI (#77) * refactor(form): extract Jinja templates and lock form JS in CI Split the monolithic generate_form f-string into form.html.j2/css/js plus admin.js, inject a single window.CONFIG blob, and add structural plus Playwright coverage so qty delegation and clamp stay green in CI. * Update src/server/generate_form.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(form): isolate admin script bindings * fix(form): address admin and form review findings * fix(form): resolve remaining review nitpicks * ci(workflow): restore required check context Keep the reusable workflow caller job compatible with the organization-required ci / ci status check. * fix(form): address remaining review findings * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit --- .github/workflows/ci.yml | 28 +- requirements.txt | 1 + src/server/generate_form.py | 1037 ++--------------------------- src/server/templates/admin.js | 425 ++++++++++++ src/server/templates/form.css | 586 ++++++++++++++++ src/server/templates/form.html.j2 | 153 +++++ src/server/templates/form.js | 609 +++++++++++++++++ tests/fixtures/config.json | 11 + tests/fixtures/menu.json | 60 ++ tests/test_generate_form.py | 294 ++++++++ 10 files changed, 2224 insertions(+), 980 deletions(-) create mode 100644 src/server/templates/admin.js create mode 100644 src/server/templates/form.css create mode 100644 src/server/templates/form.html.j2 create mode 100644 src/server/templates/form.js create mode 100644 tests/fixtures/config.json create mode 100644 tests/fixtures/menu.json create mode 100644 tests/test_generate_form.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43590d5..afaab59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,4 +10,30 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2 with: - run-tests: true + run-tests: false + + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.x + with: + python-version: "3.12" + + - name: Install Python dependencies + shell: bash + run: | + pip install pytest + while IFS= read -r -d '' req; do + pip install -r "$req" + done < <(find . -name requirements.txt -not -path './.aws-sam/*' -print0) + + - name: Install Playwright Chromium + run: playwright install --with-deps chromium + + - name: Run tests + run: pytest diff --git a/requirements.txt b/requirements.txt index d3ca079..721c3af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ playwright==1.61.0 flask==3.1.3 boto3==1.43.58 +jinja2==3.1.6 diff --git a/src/server/generate_form.py b/src/server/generate_form.py index 5ab9b96..b475992 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -15,9 +15,12 @@ import sys from datetime import datetime from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + PROJECT_ROOT = Path(__file__).resolve().parents[2] OUTPUT_DIR = PROJECT_ROOT / "output" CONFIG_PATH = PROJECT_ROOT / "config.json" +TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" def load_config(): @@ -34,6 +37,16 @@ def latest_menu() -> dict: return json.load(f) +def _json_for_script(value) -> str: + """Serialize for embedding in a ' - 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"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "") - + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") - + (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "") - + "
" - if has_discount - else "" + form_config = { + "authMode": auth_mode, + "meals": menu["meals"], + "roster": config.get("roster", []), + "week": week, + "submitUrl": f"{api_url}/api/submit-order" if api_url else "/api/submit-order", + "statusUrl": ( + f"{api_url}/api/form-status/{week}" + if api_url + else f"/api/form-status/{week}" + ), + "rosterUrl": f"{api_url}/api/roster" if api_url else "/api/roster", + "adminUrl": f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders", + "adminPdfUrl": ( + f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf" + ), + "apiKey": api_key, + "bulkDiscount": bulk_discount, + "companySubsidy": company_subsidy, + "googleClientId": google_client_id if use_google_auth else "", + "deadline": deadline, + "scrapedAt": scraped_at, + "hasDiscount": has_discount, } -{auth_section_html} + env = Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + autoescape=select_autoescape(["html", "xml", "j2"]), + ) + template = env.get_template("form.html.j2") - - -
- -
- - - - - - -
- - - -""" + return template.render( + config=form_config, + config_json=_json_for_script(form_config), + css=(TEMPLATES_DIR / "form.css").read_text(), + form_js=(TEMPLATES_DIR / "form.js").read_text(), + admin_js=(TEMPLATES_DIR / "admin.js").read_text(), + week=week, + scraped_at=scraped_at, + deadline=deadline, + has_discount=has_discount, + bulk_discount=bulk_discount, + company_subsidy=company_subsidy, + bulk_discount_label=f"{bulk_discount:g}", + company_subsidy_label=f"{company_subsidy:g}", + ) def main(): diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js new file mode 100644 index 0000000..53fcfe3 --- /dev/null +++ b/src/server/templates/admin.js @@ -0,0 +1,425 @@ +/* ADMIN_JS_MARKER */ +(() => { +const ADMIN_URL = window.ADMIN_URL; +const ADMIN_PDF_URL = window.ADMIN_PDF_URL; +const MEALS = window.MEALS; +const WEEK = window.WEEK; + +let currentAdminWeek = ''; +let editingOrder = null; +let editQuantities = {}; +let lastAdminData = null; + +function adminHeaders() { + const h = {}; + const cred = window.googleCredential; + if (cred) h['Authorization'] = 'Bearer ' + cred; + return h; +} + +function loadWeekOrders(week) { + if (!week) return; + currentAdminWeek = week; + editingOrder = null; + editQuantities = {}; + const tbody = document.getElementById('admin-tbody'); + tbody.innerHTML = 'Loading...'; + document.getElementById('admin-empty').classList.add('is-hidden'); + document.getElementById('admin-stats').textContent = ''; + + fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) + .then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(data => renderAdminTable(data)) + .catch(() => { + tbody.innerHTML = 'Failed to load orders.'; + }); +} + +window.loadWeekOrders = loadWeekOrders; + +function buildEditQtyRow(name, qty) { + const wrap = document.createElement('div'); + wrap.className = qty === 0 ? 'edit-qty removed' : 'edit-qty'; + + const minusBtn = document.createElement('button'); + minusBtn.type = 'button'; + minusBtn.dataset.adminAction = 'edit-qty'; + minusBtn.dataset.mealName = name; + minusBtn.dataset.delta = '-1'; + minusBtn.innerHTML = '−'; + + const span = document.createElement('span'); + span.textContent = qty; + + const plusBtn = document.createElement('button'); + plusBtn.type = 'button'; + plusBtn.dataset.adminAction = 'edit-qty'; + plusBtn.dataset.mealName = name; + plusBtn.dataset.delta = '1'; + plusBtn.textContent = '+'; + + wrap.appendChild(minusBtn); + wrap.appendChild(span); + wrap.appendChild(plusBtn); + wrap.appendChild(document.createTextNode(' ' + name)); + return wrap; +} + +function buildAddItemSelect(availableMeals) { + const wrap = document.createElement('div'); + wrap.className = 'admin-add-item'; + const select = document.createElement('select'); + select.dataset.adminAction = 'add-item-select'; + + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = '+ Add item...'; + select.appendChild(defaultOpt); + + availableMeals.forEach(m => { + const opt = document.createElement('option'); + const price = m.price || 0; + opt.value = m.name; + opt.textContent = m.name + ' ($' + price.toFixed(2) + ')'; + select.appendChild(opt); + }); + + wrap.appendChild(select); + return wrap; +} + +function buildActionButton(label, action, extra) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.dataset.adminAction = action; + if (extra) { + Object.entries(extra).forEach(([key, val]) => { + btn.dataset[key] = val; + }); + } + btn.textContent = label; + return btn; +} + +function buildEditActions(email) { + const wrap = document.createElement('div'); + wrap.className = 'admin-actions'; + const saveBtn = buildActionButton('Save', 'save-edit', { email }); + saveBtn.className = 'btn btn-sm btn-save'; + const cancelBtn = buildActionButton('Cancel', 'cancel-edit'); + cancelBtn.className = 'btn btn-sm btn-cancel'; + wrap.appendChild(saveBtn); + wrap.appendChild(cancelBtn); + return wrap; +} + +function buildViewActions(email, name) { + const wrap = document.createElement('div'); + wrap.className = 'admin-actions'; + const editBtn = buildActionButton('Edit', 'start-edit', { email }); + editBtn.className = 'btn btn-sm btn-edit'; + const deleteBtn = buildActionButton('Delete', 'delete', { email, name }); + deleteBtn.className = 'btn btn-sm btn-delete'; + wrap.appendChild(editBtn); + wrap.appendChild(deleteBtn); + return wrap; +} + +function renderAdminTable(data) { + lastAdminData = data; + const hasOrders = !!(data.orders && data.orders.length > 0); + const dlBtn = document.getElementById('admin-download-btn'); + if (dlBtn) dlBtn.disabled = !hasOrders; + const tbody = document.getElementById('admin-tbody'); + tbody.innerHTML = ''; + + if (!hasOrders) { + document.getElementById('admin-empty').classList.remove('is-hidden'); + document.getElementById('admin-stats').textContent = ''; + return; + } + + document.getElementById('admin-empty').classList.add('is-hidden'); + const statsEl = document.getElementById('admin-stats'); + statsEl.replaceChildren(); + const empStrong = document.createElement('strong'); + empStrong.textContent = String(data.total_employees); + statsEl.appendChild(empStrong); + statsEl.appendChild( + document.createTextNode( + ' employee' + (data.total_employees !== 1 ? 's' : '') + ' ordered · ' + ) + ); + const totalStrong = document.createElement('strong'); + totalStrong.textContent = '$' + data.grand_total.toFixed(2); + statsEl.appendChild(totalStrong); + statsEl.appendChild(document.createTextNode(' total payroll deductions')); + + data.orders.forEach((order, idx) => { + const tr = document.createElement('tr'); + tr.id = 'admin-row-' + idx; + const isEditing = editingOrder === order.employee_email; + + const empTd = document.createElement('td'); + const nameStrong = document.createElement('strong'); + nameStrong.textContent = order.employee_name; + empTd.appendChild(nameStrong); + empTd.appendChild(document.createElement('br')); + const emailSpan = document.createElement('span'); + emailSpan.className = 'admin-muted'; + emailSpan.textContent = order.employee_email; + empTd.appendChild(emailSpan); + + const itemsTd = document.createElement('td'); + itemsTd.className = 'items-cell'; + + let totalHtml; + if (isEditing) { + const editNames = Object.keys(editQuantities); + editNames.forEach(name => { + itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0)); + itemsTd.appendChild(document.createElement('br')); + }); + const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)); + if (availableMeals.length > 0) { + itemsTd.appendChild(buildAddItemSelect(availableMeals)); + } + const menuPrices = {}; + MEALS.forEach(m => { menuPrices[m.name] = m.price; }); + const newTotal = editNames.reduce((sum, name) => { + const price = menuPrices[name] || 0; + return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0); + }, 0); + totalHtml = '$' + newTotal.toFixed(2); + tr.appendChild(empTd); + tr.appendChild(itemsTd); + } else { + order.items.forEach((item, itemIdx) => { + if (itemIdx > 0) itemsTd.appendChild(document.createElement('br')); + itemsTd.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' ')); + const sub = document.createElement('span'); + sub.className = 'admin-muted'; + sub.textContent = '($' + item.subtotal.toFixed(2) + ')'; + itemsTd.appendChild(sub); + }); + totalHtml = '$' + order.total.toFixed(2); + tr.appendChild(empTd); + tr.appendChild(itemsTd); + } + + const totalTd = document.createElement('td'); + totalTd.className = 'total-cell'; + totalTd.textContent = totalHtml; + + const timeTd = document.createElement('td'); + timeTd.className = 'time-cell'; + timeTd.textContent = order.submitted_at + ? new Date(order.submitted_at).toLocaleString('en-US', { + timeZone: 'America/New_York', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + : ''; + + const actionsTd = document.createElement('td'); + if (isEditing) { + actionsTd.appendChild(buildEditActions(order.employee_email)); + } else { + actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name)); + } + + tr.appendChild(totalTd); + tr.appendChild(timeTd); + tr.appendChild(actionsTd); + tbody.appendChild(tr); + }); + + const totalRow = document.createElement('tr'); + totalRow.className = 'admin-total-row'; + const mealCount = data.orders.reduce((s, o) => s + o.items.reduce((a, i) => a + i.quantity, 0), 0); + const t1 = document.createElement('td'); + t1.textContent = 'Total'; + const t2 = document.createElement('td'); + t2.textContent = mealCount + ' meals'; + const t3 = document.createElement('td'); + t3.className = 'total-cell'; + t3.textContent = '$' + data.grand_total.toFixed(2); + totalRow.appendChild(t1); + totalRow.appendChild(t2); + totalRow.appendChild(t3); + totalRow.appendChild(document.createElement('td')); + totalRow.appendChild(document.createElement('td')); + tbody.appendChild(totalRow); +} + +function downloadOrderList() { + const data = lastAdminData; + if (!data || !data.orders || data.orders.length === 0) return; + const totals = {}; + data.orders.forEach(o => (o.items || []).forEach(i => { + const name = i.name || ''; + totals[name] = (totals[name] || 0) + (i.quantity || 0); + })); + const names = Object.keys(totals).sort((a, b) => a.localeCompare(b)); + const esc = v => { + const s = String(v); + const safe = /^[=+\-@\t\r]/.test(s) ? "'" + s : s; + return /[",\n]/.test(safe) ? '"' + safe.replace(/"/g, '""') + '"' : safe; + }; + const rows = [['Item', 'Quantity']].concat(names.map(n => [n, totals[n]])); + const csv = rows.map(r => r.map(esc).join(',')).join('\r\n'); + const week = data.week || currentAdminWeek || WEEK; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'order-list-' + week + '.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function downloadSummaryPdf() { + const week = currentAdminWeek || WEEK; + const btn = document.getElementById('admin-pdf-btn'); + if (btn) btn.disabled = true; + fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) + .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data }))) + .then(({ ok, status, data }) => { + if (ok && data.url) { + // Only follow https presigned URLs from our admin API. + if (typeof data.url === 'string' && data.url.startsWith('https://')) { + window.location.href = data.url; + } else { + alert('Could not fetch the summary PDF: invalid URL'); + } + } else if (status === 404) { + alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.'); + } else { + alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error')); + } + }) + .catch(() => alert('Could not fetch the summary PDF.')) + .finally(() => { if (btn) btn.disabled = false; }); +} + +function adminStartEdit(email) { + fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() }) + .then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(data => { + lastAdminData = data; + const order = data.orders.find(o => o.employee_email === email); + if (!order) return; + editingOrder = email; + editQuantities = {}; + order.items.forEach(i => { editQuantities[i.name] = i.quantity; }); + renderAdminTable(data); + }) + .catch(() => alert('Failed to load this order for editing.')); +} + +function adminCancelEdit() { + editingOrder = null; + editQuantities = {}; + if (lastAdminData) renderAdminTable(lastAdminData); +} + +function adminEditQty(name, delta) { + const current = editQuantities[name] || 0; + editQuantities[name] = Math.max(0, current + delta); + if (lastAdminData) renderAdminTable(lastAdminData); +} + +function adminAddItem(name) { + if (!name) return; + editQuantities[name] = 1; + if (lastAdminData) renderAdminTable(lastAdminData); +} + +function adminSaveEdit(email) { + const items = Object.entries(editQuantities) + .filter(([, q]) => q > 0) + .map(([name, quantity]) => ({ name, quantity })); + if (items.length === 0) { + if (confirm('All quantities are zero. Delete this order instead?')) { + adminDeleteOrder(email, ''); + } + return; + } + fetch(ADMIN_URL, { + method: 'PUT', + headers: { ...adminHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ week: currentAdminWeek, email, items }), + }) + .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) + .then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); }) + .catch(e => alert('Failed to update: ' + e.message)); +} + +function adminDeleteOrder(email, name) { + const label = name ? name + ' (' + email + ')' : email; + if (!confirm('Delete order for ' + label + '?')) return; + fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), { + method: 'DELETE', + headers: adminHeaders(), + }) + .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) + .then(() => loadWeekOrders(currentAdminWeek)) + .catch(e => alert('Failed to delete: ' + e.message)); +} + +function handleAdminPanelClick(e) { + const btn = e.target.closest('[data-admin-action]'); + if (btn) { + const action = btn.dataset.adminAction; + if (action === 'start-edit') { + adminStartEdit(btn.dataset.email); + } else if (action === 'delete') { + adminDeleteOrder(btn.dataset.email, btn.dataset.name || ''); + } else if (action === 'save-edit') { + adminSaveEdit(btn.dataset.email); + } else if (action === 'cancel-edit') { + adminCancelEdit(); + } else if (action === 'edit-qty') { + adminEditQty(btn.dataset.mealName, parseInt(btn.dataset.delta, 10)); + } else if (action === 'download-list') { + downloadOrderList(); + } else if (action === 'download-pdf') { + downloadSummaryPdf(); + } else if (action === 'hide-admin') { + window.hideAdmin(); + } + return; + } +} + +function adminInit() { + const panel = document.getElementById('admin-panel'); + if (panel) { + panel.addEventListener('click', handleAdminPanelClick); + panel.addEventListener('change', (e) => { + const select = e.target.closest('[data-admin-action="add-item-select"]'); + if (select && select.value) { + adminAddItem(select.value); + select.selectedIndex = 0; + } + }); + } + + const weekSelect = document.getElementById('admin-week'); + if (weekSelect) { + weekSelect.addEventListener('change', () => loadWeekOrders(weekSelect.value)); + } +} + +adminInit(); +})(); diff --git a/src/server/templates/form.css b/src/server/templates/form.css new file mode 100644 index 0000000..f4c02c6 --- /dev/null +++ b/src/server/templates/form.css @@ -0,0 +1,586 @@ +/* FORM_CSS_MARKER */ +:root { + --color-brand: #1a1a2e; + --color-muted: #6b7280; + --color-border: #d1d5db; + --radius-card: 12px; + --radius-control: 8px; + --shadow-card: 0 1px 3px rgba(0,0,0,0.08); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f7; + color: #1d1d1f; + padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); +} + +.is-hidden { display: none !important; } + +.container { max-width: 1400px; margin: 0 auto; padding: 16px; } + +#meals-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); + gap: 12px; +} + +header { + background: var(--color-brand); + color: #fff; + padding: 24px 16px; + text-align: center; + margin-bottom: 24px; + border-radius: var(--radius-card); +} + +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: var(--radius-control); + margin-bottom: 20px; + font-size: 0.9rem; + text-align: center; + font-weight: 500; +} + +.employee-info { + background: #fff; + border-radius: var(--radius-card); + padding: 16px; + margin-bottom: 20px; + box-shadow: var(--shadow-card); +} + +.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 var(--color-border); + border-radius: var(--radius-control); + font-size: 1rem; + margin-bottom: 12px; +} + +.user-info-row { display: flex; align-items: center; gap: 12px; } + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + display: none; +} + +.user-avatar:not(.is-hidden) { display: block; } + +.user-meta { flex: 1; } +.user-name { font-weight: 600; font-size: 0.95rem; } +.user-email { font-size: 0.85rem; color: var(--color-muted); } +.count-display { font-size: 0.8rem; color: var(--color-muted); margin-left: 8px; } +.g-signin-wrap { display: flex; justify-content: center; } +.admin-muted { font-size: 0.8rem; color: #9ca3af; } + +.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } + +.meal-card { + background: #fff; + border-radius: var(--radius-card); + padding: 12px; + margin-bottom: 0; + box-shadow: var(--shadow-card); + display: flex; + gap: 12px; + align-items: center; +} + +.meal-img { + width: 80px; + height: 80px; + border-radius: var(--radius-control); + 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: var(--color-muted); + 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; } + +.meal-desc { + font-size: 0.8rem; + color: var(--color-muted); + margin-bottom: 4px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +} + +.qty-control { + display: flex; + align-items: center; + gap: 0; + flex-shrink: 0; +} + +.qty-control button { + width: 32px; + height: 32px; + border: 1px solid var(--color-border); + background: #f9fafb; + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.qty-control button:first-child { border-radius: var(--radius-control) 0 0 var(--radius-control); } +.qty-control button:last-child { border-radius: 0 var(--radius-control) var(--radius-control) 0; } + +.qty-control .qty { + width: 36px; + height: 32px; + text-align: center; + border: 1px solid var(--color-border); + border-left: 0; + border-right: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.meal-card.selected { border: 2px solid var(--color-brand); } + +.sticky-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-top: 1px solid #e5e7eb; + padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -2px 8px rgba(0,0,0,0.1); + z-index: 100; +} + +.sticky-footer .inner { + max-width: 1400px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.total { font-size: 1.1rem; font-weight: 700; } + +.btn { + cursor: pointer; + font-weight: 600; + border-radius: var(--radius-control); + transition: opacity 0.2s, background 0.2s, color 0.2s; + font-family: inherit; +} + +.btn-primary { + background: var(--color-brand); + color: #fff; + border: none; + padding: 12px 32px; + font-size: 1rem; + font-weight: 600; +} + +.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-primary:hover:not(:disabled) { opacity: 0.85; } + +.btn-outline { + background: transparent; + color: var(--color-brand); + border: 2px solid var(--color-brand); + padding: 12px 32px; + font-size: 1rem; + font-weight: 600; +} + +.btn-outline:hover { background: var(--color-brand); color: #fff; } + +.btn-sm { + padding: 4px 10px; + font-size: 0.78rem; + border-radius: 6px; + font-weight: 500; +} + +.btn-sm.btn-edit, +.btn-sm.btn-delete, +.btn-sm.btn-save, +.btn-sm.btn-cancel { + border: 1px solid; +} + +.btn-chip { + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: 20px; + background: #fff; + font-size: 0.8rem; + font-weight: 500; +} + +.btn-chip.active { + background: var(--color-brand); + color: #fff; + border-color: var(--color-brand); +} + +.submit-btn, +.btn.btn-primary.submit-btn { + background: var(--color-brand); + color: #fff; + border: none; + padding: 12px 32px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-control); + transition: opacity 0.2s; +} + +.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.submit-btn:hover:not(:disabled) { opacity: 0.85; } + +.back-btn, +.btn.btn-outline.back-btn { + background: transparent; + color: var(--color-brand); + border: 2px solid var(--color-brand); + padding: 12px 32px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-control); + transition: all 0.2s; + margin-top: 20px; +} + +.back-btn:hover { background: var(--color-brand); color: #fff; } + +.admin-toolbar-btn, +.btn.btn-primary.btn-sm.admin-toolbar-btn { + margin: 0; + padding: 8px 16px; + font-size: 0.85rem; +} + +.success-msg { text-align: center; padding: 60px 20px; } +.success-msg h2 { color: #15803d; margin-bottom: 8px; } + +.duplicate-warning { + background: #fef3c7; + color: #92400e; + padding: 10px 16px; + border-radius: var(--radius-control); + margin-bottom: 20px; + font-size: 0.9rem; + text-align: center; + font-weight: 500; + border: 1px solid #fcd34d; +} + +.search-bar { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + font-size: 1rem; + margin-bottom: 16px; +} + +.closed-banner { + background: #fee2e2; + color: #991b1b; + padding: 16px; + border-radius: var(--radius-control); + margin-bottom: 20px; + text-align: center; + font-weight: 600; + font-size: 1rem; +} + +@media (max-width: 480px) { + .meal-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + } + .qty-control { + grid-column: 1 / -1; + justify-content: center; + } +} + +@media (pointer: coarse) { + .qty-control button { width: 44px; height: 44px; } + .qty-control .qty { width: 44px; height: 44px; } + .edit-qty button { width: 44px; height: 44px; } + .edit-qty span { width: 44px; height: 44px; line-height: 44px; } +} + +.discount-banner { + background: #dcfce7; + color: #15803d; + padding: 10px 16px; + border-radius: var(--radius-control); + 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: var(--color-brand); + 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: var(--color-muted); margin-bottom: 32px; } +.auth-card .logo { font-size: 2rem; margin-bottom: 16px; } + +.closed-overlay { + position: fixed; + inset: 0; + background: var(--color-brand); + display: none; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.closed-overlay.is-visible { display: flex; } + +.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: var(--color-muted); margin-bottom: 24px; } +.closed-card .logo { font-size: 2.5rem; margin-bottom: 16px; } + +.countdown { + font-size: 2rem; + font-weight: 700; + color: var(--color-brand); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.countdown-label { font-size: 0.75rem; color: #9ca3af; margin-top: 4px; } + +.admin-panel { + background: #fff; + border-radius: var(--radius-card); + padding: 20px; + box-shadow: var(--shadow-card); +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.admin-header h2 { font-size: 1.2rem; font-weight: 700; margin: 0; } + +.admin-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.admin-toolbar select { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + font-size: 0.9rem; +} + +.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; } + +@media (max-width: 600px) { + .admin-toolbar { width: 100%; } + .admin-toolbar > * { flex: 1 1 100%; } +} + +.admin-stats { font-size: 0.9rem; color: var(--color-muted); margin-bottom: 16px; } +.admin-stats strong { color: #1d1d1f; } + +.admin-table-wrap { overflow-x: auto; } + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.admin-table th { + text-align: left; + padding: 10px 12px; + border-bottom: 2px solid #e5e7eb; + font-weight: 600; + color: var(--color-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.admin-table td { + padding: 10px 12px; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; +} + +.admin-table tr:last-child td { border-bottom: none; } +.admin-table .items-cell { font-size: 0.82rem; color: #4b5563; } +.admin-table .total-cell { font-weight: 600; white-space: nowrap; } +.admin-table .time-cell { font-size: 0.8rem; color: #9ca3af; white-space: nowrap; } + +.admin-total-row { background: #f9fafb; font-weight: 700; } +.admin-total-row td { border-top: 2px solid #e5e7eb; padding: 12px; } + +.admin-empty { text-align: center; padding: 40px 20px; color: #9ca3af; } + +.admin-actions { display: flex; gap: 6px; white-space: nowrap; flex-wrap: wrap; } + +.btn-edit { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; } +.btn-edit:hover { background: #dbeafe; } + +.btn-delete { background: #fef2f2; color: #dc2626; border-color: #fecaca; } +.btn-delete:hover { background: #fee2e2; } + +.btn-save { background: #dcfce7; color: #15803d; border-color: #bbf7d0; } +.btn-save:hover { background: #bbf7d0; } + +.btn-cancel { background: #f9fafb; color: var(--color-muted); border-color: var(--color-border); } +.btn-cancel:hover { background: #f3f4f6; } + +.edit-qty { + display: inline-flex; + align-items: center; + gap: 0; + margin: 2px 0; +} + +.edit-qty button { + width: 24px; + height: 24px; + border: 1px solid var(--color-border); + background: #f9fafb; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.edit-qty button:first-child { border-radius: 4px 0 0 4px; } +.edit-qty button:last-child { border-radius: 0 4px 4px 0; } + +.edit-qty span { + width: 28px; + height: 24px; + text-align: center; + line-height: 24px; + border: 1px solid var(--color-border); + border-left: 0; + border-right: 0; + font-size: 0.82rem; + font-weight: 600; +} + +.edit-qty.removed { opacity: 0.4; text-decoration: line-through; } + +.admin-add-item { margin-top: 8px; } + +.admin-add-item select { + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 0.8rem; + max-width: 220px; +} + +.admin-loading, +.admin-error { + text-align: center; + padding: 20px; +} + +.admin-loading { color: #9ca3af; } +.admin-error { color: #991b1b; } diff --git a/src/server/templates/form.html.j2 b/src/server/templates/form.html.j2 new file mode 100644 index 0000000..905eb91 --- /dev/null +++ b/src/server/templates/form.html.j2 @@ -0,0 +1,153 @@ + + + + + +Sea Haven — Meal Order ({{ week }}) + + +{% if config.authMode == 'google' %} + +{% endif %} + + + +
+
+ +

Orders Are Closed

+

Orders open again Monday at 8:00 AM ET

+
+
until orders open
+
+
+ +{% if config.authMode == 'google' %} +
+
+ +

Sea Haven Meal Order

+

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

+ +
+
+{% endif %} + +
+
+

Sea Haven Meal Order

+

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

+
+ +
Order deadline: {{ deadline }}
+ + + {% if has_discount %} +
Prices reflect employee cost after {% if bulk_discount > 0 %}{{ bulk_discount_label }}% bulk discount{% endif %}{% if bulk_discount > 0 and company_subsidy > 0 %} + {% endif %}{% if company_subsidy > 0 %}{{ company_subsidy_label }}% company subsidy{% endif %}
+ {% endif %} + + {% if config.authMode == 'google' %} +
+
+ +
+
+ {% else %} +
+ + + +
+ {% endif %} + + + +
+ + + +
+ + + + + + +
+ + + + + + diff --git a/src/server/templates/form.js b/src/server/templates/form.js new file mode 100644 index 0000000..c3f175f --- /dev/null +++ b/src/server/templates/form.js @@ -0,0 +1,609 @@ +/* FORM_JS_MARKER */ +const CONFIG = window.CONFIG; +const MEALS = CONFIG.meals; +const ROSTER = CONFIG.roster; +const SUBMIT_URL = CONFIG.submitUrl; +const STATUS_URL = CONFIG.statusUrl; +const ROSTER_URL = CONFIG.rosterUrl; +const ADMIN_URL = CONFIG.adminUrl; +const ADMIN_PDF_URL = CONFIG.adminPdfUrl; +const API_KEY = CONFIG.apiKey; +const WEEK = CONFIG.week; +const BULK_DISCOUNT = CONFIG.bulkDiscount; +const COMPANY_SUBSIDY = CONFIG.companySubsidy; +const GOOGLE_CLIENT_ID = CONFIG.googleClientId || ''; + +window.MEALS = MEALS; +window.WEEK = WEEK; +window.ADMIN_URL = ADMIN_URL; +window.ADMIN_PDF_URL = ADMIN_PDF_URL; + +const quantities = {}; +let formClosed = false; +let formStatusData = null; +let isAdmin = false; +let adminWeeks = []; +let googleCredential = null; +let googleUser = null; +let reopenAtMs = null; +let countdownTimer = null; +let mealsListDelegationBound = false; + +window.googleCredential = null; + +function employeePrice(retail) { + if (!retail) return 0; + const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100; + const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100; + return empPrice; +} + +window.FormUtils = { employeePrice }; + +function syncGoogleCredential() { + window.googleCredential = googleCredential; +} + +function buildItems() { + return Object.entries(quantities).map(([i, qty]) => ({ + name: MEALS[i].name, + retail_price: MEALS[i].price, + quantity: qty, + })); +} + +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, + 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; + syncGoogleCredential(); + const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); + googleUser = { name: payload.name, email: payload.email }; + + document.getElementById('auth-overlay').classList.add('is-hidden'); + document.getElementById('app').classList.remove('is-hidden'); + const footer = document.getElementById('sticky-footer'); + if (footer) footer.classList.remove('is-hidden'); + 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.classList.remove('is-hidden'); + } + + updateTotal(); + checkDuplicateOrder(); + checkAdmin().then(() => { + if (formClosed && !isAdmin) showClosedOverlay(); + }); +} + +function signOut() { + isAdmin = false; + const ab = document.getElementById('admin-btn'); + if (ab) ab.classList.add('is-hidden'); + const banner = document.getElementById('admin-closed-banner'); + if (banner) banner.remove(); + googleCredential = null; + googleUser = null; + syncGoogleCredential(); + if (CONFIG.authMode === 'google' && typeof google !== 'undefined' && google.accounts) { + google.accounts.id.disableAutoSelect(); + } + document.getElementById('auth-overlay').classList.remove('is-hidden'); + document.getElementById('app').classList.add('is-hidden'); + const footer = document.getElementById('sticky-footer'); + if (footer) footer.classList.add('is-hidden'); + if (formClosed) showClosedOverlay(); +} + +function checkDuplicateOrder() { + try { + if (localStorage.getItem('lastOrderWeek') === WEEK) { + document.getElementById('duplicate-warning').classList.remove('is-hidden'); + } + } catch (e) {} +} + +function getActiveFilter() { + return document.querySelector('.filters button.active')?.textContent || 'All Meals'; +} + +function mealMatchesFilter(meal, activeFilter, search) { + if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return false; + if (search && !meal.name.toLowerCase().includes(search)) return false; + return true; +} + +function buildMealMetaHtml(meal) { + const hasDiscount = CONFIG.hasDiscount && meal.price; + const empPrice = employeePrice(meal.price); + const meta = document.createElement('div'); + meta.className = 'meal-meta'; + if (hasDiscount) { + const retail = document.createElement('span'); + retail.className = 'price-retail'; + retail.textContent = '$' + meal.price.toFixed(2); + meta.appendChild(retail); + const employee = document.createElement('span'); + employee.className = 'price-employee'; + employee.textContent = '$' + empPrice.toFixed(2); + meta.appendChild(employee); + } else if (meal.price) { + meta.appendChild(document.createTextNode('$' + meal.price.toFixed(2))); + } else { + meta.appendChild(document.createTextNode('—')); + } + meta.appendChild(document.createTextNode(' · ' + (meal.calories || '?') + ' cal · ' + (meal.protein || '?') + ' protein')); + return meta; +} + +function buildMealTagsEl(meal) { + const tagsEl = document.createElement('div'); + tagsEl.className = 'meal-tags'; + if (meal.is_new) { + const span = document.createElement('span'); + span.className = 'new'; + span.textContent = 'NEW'; + tagsEl.appendChild(span); + } + (meal.dietary_tags || []).forEach(t => { + const span = document.createElement('span'); + span.textContent = t; + tagsEl.appendChild(span); + }); + return tagsEl; +} + +function updateMealCard(card, meal, i) { + const qty = quantities[i] || 0; + card.dataset.mealIndex = String(i); + card.classList.toggle('selected', qty > 0); + + const img = card.querySelector('.meal-img'); + if (meal.image_url && /^https?:\/\//i.test(meal.image_url)) { + img.src = meal.image_url; + img.alt = meal.name; + img.classList.remove('is-hidden'); + } else { + img.removeAttribute('src'); + img.alt = ''; + img.classList.add('is-hidden'); + } + + card.querySelector('.meal-name').textContent = meal.name; + + const descEl = card.querySelector('.meal-desc'); + if (meal.description) { + descEl.textContent = meal.description; + descEl.classList.remove('is-hidden'); + } else { + descEl.textContent = ''; + descEl.classList.add('is-hidden'); + } + + const metaEl = card.querySelector('.meal-meta'); + const newMeta = buildMealMetaHtml(meal); + metaEl.replaceWith(newMeta); + + const tagsEl = card.querySelector('.meal-tags'); + const newTags = buildMealTagsEl(meal); + tagsEl.replaceWith(newTags); + + const qtyInput = card.querySelector('.qty'); + qtyInput.value = qty; + + const disabled = formClosed && !isAdmin; + card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; }); + qtyInput.disabled = disabled; +} + +function createMealCard(meal, i) { + const tpl = document.getElementById('meal-card-tpl'); + const card = tpl.content.firstElementChild.cloneNode(true); + updateMealCard(card, meal, i); + return card; +} + +function renderMeals() { + const activeFilter = getActiveFilter(); + const search = document.getElementById('search').value.toLowerCase(); + const list = document.getElementById('meals-list'); + list.innerHTML = ''; + + MEALS.forEach((meal, i) => { + if (!mealMatchesFilter(meal, activeFilter, search)) return; + list.appendChild(createMealCard(meal, i)); + }); + + updateTotal(); +} + +function findMealCard(index) { + return document.querySelector(`[data-meal-index="${index}"]`); +} + +function updateMealCardQty(index) { + const card = findMealCard(index); + if (!card) return; + const qty = quantities[index] || 0; + card.classList.toggle('selected', qty > 0); + const qtyInput = card.querySelector('.qty'); + if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty; +} + +function setQty(index, value) { + const next = Math.max(0, parseInt(value, 10) || 0); + if (next === 0) delete quantities[index]; + else quantities[index] = next; + updateMealCardQty(index); + updateTotal(); +} + +function changeQty(index, delta) { + const current = quantities[index] || 0; + setQty(index, current + delta); +} + +function bindMealsListDelegation() { + if (mealsListDelegationBound) return; + mealsListDelegationBound = true; + const list = document.getElementById('meals-list'); + list.addEventListener('click', (e) => { + const btn = e.target.closest('[data-qty-delta]'); + if (!btn) return; + const card = btn.closest('[data-meal-index]'); + if (!card) return; + const index = parseInt(card.dataset.mealIndex, 10); + const delta = parseInt(btn.dataset.qtyDelta, 10); + changeQty(index, delta); + }); + list.addEventListener('input', (e) => { + const input = e.target.closest('.qty'); + if (!input) return; + const card = input.closest('[data-meal-index]'); + if (!card) return; + const index = parseInt(card.dataset.mealIndex, 10); + setQty(index, input.value); + }); + list.addEventListener('focusout', (e) => { + const input = e.target.closest('.qty'); + if (!input) return; + const card = input.closest('[data-meal-index]'); + if (!card) return; + updateMealCardQty(parseInt(card.dataset.mealIndex, 10)); + }); +} + +function updateTotal() { + let total = 0; + let count = 0; + Object.entries(quantities).forEach(([i, 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' : ''); + const submitBtn = document.getElementById('submit-btn'); + submitBtn.disabled = count === 0 || (CONFIG.authMode === 'google' && !googleCredential); +} + +async function submitOrder() { + if (formClosed && !isAdmin) { alert('Orders are closed.'); return; } + + let payload; + let successName; + + if (CONFIG.authMode === 'google') { + if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; } + payload = { google_id_token: googleCredential, items: buildItems() }; + successName = googleUser.name; + } else { + 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; } + payload = { employee_name: name, employee_email: email, items: buildItems() }; + successName = name; + } + + 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(payload), + }); + const data = await res.json(); + if (res.ok) { + for (const el of document.getElementById('app').children) { + if (el.id !== 'success') el.classList.add('is-hidden'); + } + document.getElementById('success').classList.remove('is-hidden'); + document.getElementById('success-detail').textContent = + successName + ' — $' + (data.total || 0).toFixed(2) + ' total. You\'re all set!'; + const footer = document.querySelector('.sticky-footer'); + if (footer) footer.classList.add('is-hidden'); + try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} + } 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 loadRoster() { + let employees = ROSTER; + try { + const res = await fetch(ROSTER_URL); + const data = await res.json(); + const candidate = data.employees; + if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) { + employees = candidate; + } else { + employees = ROSTER; + } + } catch (e) {} + + const select = document.getElementById('emp-name'); + select.innerHTML = ''; + employees.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 || ''; + }); +} + +function showClosedOverlay() { + const overlay = document.getElementById('closed-overlay'); + overlay.classList.add('is-visible'); + overlay.style.display = 'flex'; + document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; }); + const submitBtn = document.getElementById('submit-btn'); + if (submitBtn) submitBtn.disabled = true; + if (formStatusData) startCountdown(formStatusData); +} + +function adminBypassClosed() { + const overlay = document.getElementById('closed-overlay'); + overlay.classList.remove('is-visible'); + overlay.style.display = 'none'; + document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = false; }); + const submitBtn = document.getElementById('submit-btn'); + if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0; + if (!document.getElementById('admin-closed-banner')) { + const banner = document.createElement('div'); + banner.id = 'admin-closed-banner'; + banner.className = 'closed-banner'; + banner.textContent = 'Form is closed — admin override active'; + const deadline = document.querySelector('.deadline'); + if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling); + } + document.querySelectorAll('[data-meal-index]').forEach(card => { + const index = parseInt(card.dataset.mealIndex, 10); + const meal = MEALS[index]; + if (meal) updateMealCard(card, meal, index); + }); + updateTotal(); +} + +function checkAdmin() { + if (!ADMIN_URL) return Promise.resolve(); + const headers = {}; + if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential; + return fetch(ADMIN_URL, { headers }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data && data.weeks) { + isAdmin = true; + window.isAdmin = true; + adminWeeks = data.weeks; + window.adminWeeks = adminWeeks; + const ab = document.getElementById('admin-btn'); + if (ab) ab.classList.remove('is-hidden'); + if (formClosed) adminBypassClosed(); + } else if (formClosed) { + showClosedOverlay(); + } + }) + .catch(() => { + if (formClosed) showClosedOverlay(); + }); +} + +function showAdmin() { + document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { + if (el.id !== 'admin-panel') { + el.dataset.prevHidden = el.classList.contains('is-hidden') ? '1' : ''; + el.classList.add('is-hidden'); + } + }); + document.getElementById('admin-panel').classList.remove('is-hidden'); + const footer = document.querySelector('.sticky-footer'); + if (footer) footer.classList.add('is-hidden'); + + const select = document.getElementById('admin-week'); + select.innerHTML = ''; + adminWeeks.forEach(w => { + const opt = document.createElement('option'); + opt.value = w.week; + opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : ''); + select.appendChild(opt); + }); + if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') { + loadWeekOrders(adminWeeks[0].week); + } +} + +function hideAdmin() { + document.getElementById('admin-panel').classList.add('is-hidden'); + document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { + if (el.dataset.prevHidden === '1') el.classList.add('is-hidden'); + else el.classList.remove('is-hidden'); + delete el.dataset.prevHidden; + }); + const footer = document.querySelector('.sticky-footer'); + if (footer) footer.classList.remove('is-hidden'); +} + +const easternFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', +}); + +function getEasternParts(date) { + const values = {}; + easternFormatter.formatToParts(date).forEach(part => { + if (part.type !== 'literal') values[part.type] = Number(part.value); + }); + return values; +} + +function getEasternOffsetMs(date) { + const parts = getEasternParts(date); + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime(); +} + +function startCountdown(data) { + if (data && data.reopen_at) reopenAtMs = data.reopen_at * 1000; + updateCountdown(); + if (countdownTimer !== null) clearInterval(countdownTimer); + countdownTimer = setInterval(updateCountdown, 1000); +} + +function updateCountdown() { + const now = new Date(); + let target; + + if (reopenAtMs) { + target = new Date(reopenAtMs); + } else { + const etNow = getEasternParts(now); + const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay(); + const hour = etNow.hour; + + let daysUntil = (1 - day + 7) % 7; + if (daysUntil === 0 && hour >= 8) daysUntil = 7; + + const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0); + target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs))); + } + + let diff = Math.max(0, Math.floor((target - now) / 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'; +} + +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; + formStatusData = data; + if (isAdmin) { + adminBypassClosed(); + } else if (CONFIG.authMode !== 'google' || googleCredential) { + showClosedOverlay(); + } + } + } catch (e) {} +} + +function init() { + checkFormStatus(); + if (CONFIG.authMode === 'google') { + waitForGoogleAuth(); + } else { + loadRoster(); + checkDuplicateOrder(); + } + + 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.type = 'button'; + btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : ''); + btn.textContent = cat; + btn.addEventListener('click', () => { + 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); + + const adminBtn = document.getElementById('admin-btn'); + if (adminBtn) adminBtn.addEventListener('click', showAdmin); + + const signOutBtn = document.getElementById('sign-out-btn'); + if (signOutBtn) signOutBtn.addEventListener('click', signOut); + + const successBackBtn = document.getElementById('success-back-btn'); + if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload()); + + bindMealsListDelegation(); + renderMeals(); +} + +window.showAdmin = showAdmin; +window.hideAdmin = hideAdmin; +window.submitOrder = submitOrder; +window.changeQty = changeQty; + +init(); diff --git a/tests/fixtures/config.json b/tests/fixtures/config.json new file mode 100644 index 0000000..7054542 --- /dev/null +++ b/tests/fixtures/config.json @@ -0,0 +1,11 @@ +{ + "order_deadline": "Thursday 11:59 PM", + "roster": [ + { + "name": "Ada Lovelace", + "email": "ada@example.com" + } + ], + "bulk_discount_percent": 0, + "company_subsidy_percent": 0 +} \ No newline at end of file diff --git a/tests/fixtures/menu.json b/tests/fixtures/menu.json new file mode 100644 index 0000000..921739b --- /dev/null +++ b/tests/fixtures/menu.json @@ -0,0 +1,60 @@ +{ + "scraped_at": "2026-07-27T12:00:00+00:00", + "meal_count": 5, + "meals": [ + { + "name": "Chicken Bowl", + "price": 12.0, + "calories": "500", + "protein": "40g", + "description": "Simple bowl", + "dietary_tags": [ + "High Protein" + ], + "image_url": "", + "is_new": false + }, + { + "name": "Luis' Lomo Saltado", + "price": 14.5, + "calories": "620", + "protein": "35g", + "description": "With onions", + "dietary_tags": [], + "image_url": "", + "is_new": true + }, + { + "name": "Meal with \"quotes\"", + "price": 11.0, + "calories": "400", + "protein": "20g", + "description": "Quoted", + "dietary_tags": [ + "tag" + ], + "image_url": "", + "is_new": false + }, + { + "name": "", + "price": 9.0, + "calories": "300", + "protein": "10g", + "description": "Hostile script", + "dietary_tags": [], + "image_url": "", + "is_new": false + }, + { + "name": "", + "price": 9.0, + "calories": "300", + "protein": "10g", + "description": "Hostile script comment", + "dietary_tags": [], + "image_url": "", + "is_new": false + } + ] +} \ No newline at end of file diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py new file mode 100644 index 0000000..d9a1b0b --- /dev/null +++ b/tests/test_generate_form.py @@ -0,0 +1,294 @@ +"""Structural and Playwright tests for the generated order form.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SERVER_DIR = REPO_ROOT / "src" / "server" +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +sys.path.insert(0, str(SERVER_DIR)) + +from generate_form import generate_form # noqa: E402 + +EXPECTED_CONFIG_KEYS = { + "authMode", + "meals", + "roster", + "week", + "submitUrl", + "statusUrl", + "rosterUrl", + "adminUrl", + "adminPdfUrl", + "apiKey", + "bulkDiscount", + "companySubsidy", + "googleClientId", + "deadline", + "scrapedAt", + "hasDiscount", +} + + +def _load_fixtures(): + menu = json.loads((FIXTURES / "menu.json").read_text()) + config = json.loads((FIXTURES / "config.json").read_text()) + return menu, config + + +def _extract_config(html: str) -> dict: + match = re.search( + r"window\.CONFIG\s*=\s*(\{.*?\});", + html, + flags=re.DOTALL, + ) + assert match, "window.CONFIG blob not found" + return json.loads(match.group(1)) + + +def _render(*, google: bool = False, api_key: str = "test-key") -> str: + menu, config = _load_fixtures() + return generate_form( + menu, + config, + api_url="https://api.example.com", + api_key=api_key, + bulk_discount=10, + company_subsidy=50, + google_client_id="google-client.apps.googleusercontent.com" if google else "", + ) + + +class TestGenerateFormStructural: + def test_local_and_google_render(self): + local = _render(google=False) + google = _render(google=True) + assert "FORM_HTML_MARKER" in local + assert "FORM_HTML_MARKER" in google + assert 'id="emp-name"' in local + assert 'id="emp-name"' not in google + assert "accounts.google.com/gsi/client" in google + assert "accounts.google.com/gsi/client" not in local + assert 'id="auth-overlay"' in google + assert 'id="auth-overlay"' not in local + + def test_single_config_blob_and_keys(self): + for google in (False, True): + html = _render(google=google) + assert html.count("window.CONFIG =") == 1 + cfg = _extract_config(html) + assert EXPECTED_CONFIG_KEYS <= set(cfg.keys()) + assert cfg["authMode"] == ("google" if google else "local") + if google: + assert cfg["googleClientId"] + else: + assert cfg["googleClientId"] == "" + assert cfg["bulkDiscount"] == 10 + assert cfg["companySubsidy"] == 50 + assert cfg["hasDiscount"] is True + + def test_no_legacy_const_injections(self): + html = _render(google=True) + for pattern in ( + r"\bconst MEALS\s*=", + r"\bconst ROSTER\s*=", + r"\bconst SUBMIT_URL\s*=", + r"\bconst STATUS_URL\s*=", + r"\bconst ROSTER_URL\s*=", + r"\bconst ADMIN_URL\s*=", + r"\bconst ADMIN_PDF_URL\s*=", + r"\bconst API_KEY\s*=", + r"\bconst GOOGLE_CLIENT_ID\s*=", + ): + # Locals derived from CONFIG inside form.js are fine; generator must not + # inject the seven top-level const lines before the inlined scripts. + pre_js = html.split("/* FORM_JS_MARKER */")[0] + assert not re.search(pattern, pre_js), pattern + + def test_template_markers_inlined(self): + html = _render() + assert "FORM_CSS_MARKER" in html + assert "--color-brand" in html + assert "FORM_JS_MARKER" in html + assert "ADMIN_JS_MARKER" in html + assert "FORM_HTML_MARKER" in html + assert '" not in html + cfg_match = re.search(r"window\.CONFIG\s*=\s*(\{.*?\});", html, flags=re.DOTALL) + assert cfg_match + blob = cfg_match.group(1) + # Literal inside the CONFIG script would terminate early. + assert "" not in blob + # Parsed JSON still contains the hostile meal name. + cfg = json.loads(blob) + assert any( + m["name"] == "" for m in cfg["meals"] + ) + + def test_hostile_names_present_in_config(self): + cfg = _extract_config(_render()) + names = {m["name"] for m in cfg["meals"]} + assert "Luis' Lomo Saltado" in names + assert 'Meal with "quotes"' in names + assert "" in names + assert "" in names + + def test_meal_card_template_and_qty_input(self): + html = _render() + assert 'id="meal-card-tpl"' in html + assert 'type="number"' in html + assert "data-meal-index" in html + assert "data-qty-delta" in html + + def test_design_tokens_only_in_definitions(self): + css = (SERVER_DIR / "templates" / "form.css").read_text() + # Strip :root block, then assert brand/muted/border hexes are gone + without_root = re.sub(r":root\s*\{[^}]*\}", "", css, count=1) + assert "#1a1a2e" not in without_root + assert "#6b7280" not in without_root + assert "#d1d5db" not in without_root + + def test_python_has_no_html_js_fragments(self): + src = (SERVER_DIR / "generate_form.py").read_text() + assert "auth_section_html" not in src + assert "google_auth_js" not in src + assert "submit_order_js" not in src + assert "origLoadWeekOrders" not in src + + +class TestGenerateFormPlaywright: + @pytest.fixture + def browser_page(self, tmp_path_factory): + sync_api = pytest.importorskip("playwright.sync_api") + sync_playwright = sync_api.sync_playwright + + html = _render(google=False, api_key="") + # Stub status/roster so the page does not hang on network; roster fallback + # in loadRoster still applies if fetch fails — intercept to be deterministic. + out = tmp_path_factory.mktemp("form") / "order-form.html" + out.write_text(html) + + with sync_playwright() as p: + try: + browser = p.chromium.launch(headless=True) + except Exception as exc: + raise RuntimeError( + "Chromium is required for form Playwright tests. " + "Run: playwright install --with-deps chromium" + ) from exc + try: + page = browser.new_page() + page.route( + "**/api/form-status/**", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body='{"status":"open"}', + ), + ) + page.route( + "**/api/roster**", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "employees": [ + { + "name": "Ada Lovelace", + "email": "ada@example.com", + } + ] + } + ), + ), + ) + page.goto(out.as_uri()) + page.wait_for_selector('[data-meal-index="0"]') + yield page + finally: + browser.close() + + def test_admin_bundle_loads(self, browser_page): + assert browser_page.evaluate("typeof window.loadWeekOrders") == "function" + + def test_qty_plus_updates_total_without_list_teardown(self, browser_page): + page = browser_page + card = page.locator('[data-meal-index="0"]') + card_handle = card.element_handle() + assert card_handle is not None + + page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click() + page.wait_for_function( + "() => document.getElementById('total-display').textContent !== '$0.00'" + ) + + assert page.locator("#total-display").inner_text() == "$5.40" + assert page.locator("#count-display").inner_text() == "1 meal" + assert page.locator('[data-meal-index="0"] .qty').input_value() == "1" + assert "selected" in (card.get_attribute("class") or "") + + # Same DOM node — partial update, not full list teardown + page.evaluate( + """() => { + const el = document.querySelector('[data-meal-index="0"]'); + el.dataset.teardownProbe = '1'; + }""" + ) + page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click() + page.wait_for_function( + "() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '2'" + ) + assert ( + page.locator('[data-meal-index="0"]').get_attribute("data-teardown-probe") + == "1" + ) + assert page.locator("#count-display").inner_text() == "2 meals" + assert page.locator("#total-display").inner_text() == "$10.80" + + def test_qty_typein_clamps_negative(self, browser_page): + page = browser_page + # Reset first card via type-in + qty = page.locator('[data-meal-index="0"] .qty') + qty.fill("-3") + qty.blur() + page.wait_for_function( + "() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'" + ) + assert qty.input_value() == "0" + qty.fill("2") + qty.blur() + page.wait_for_function( + "() => document.getElementById('count-display').textContent === '2 meals'" + ) + assert page.locator("#total-display").inner_text() == "$10.80"