refactor(form): extract Jinja templates and lock form JS in CI (#77)
Some checks are pending
Deploy / deploy (push) Waiting to run

* 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 <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
Adam Moussa 2026-07-30 19:19:51 -04:00 • committed by GitHub
parent 3fe6e74557
commit 216618a862
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2224 additions and 980 deletions

View file

@ -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

View file

@ -1,3 +1,4 @@
playwright==1.61.0
flask==3.1.3
boto3==1.43.58
jinja2==3.1.6

File diff suppressed because it is too large Load diff

View file

@ -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 = '<tr><td colspan="5" class="admin-loading">Loading...</td></tr>';
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 = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
});
}
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 = '&minus;';
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();
})();

View file

@ -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; }

View file

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Sea Haven — Meal Order ({{ week }})</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
<style>
{{ css|safe }}
</style>
{% if config.authMode == 'google' %}
<script src="https://accounts.google.com/gsi/client" async defer></script>
{% endif %}
</head>
<body>
<!-- FORM_HTML_MARKER -->
<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>
{% if config.authMode == 'google' %}
<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" class="g-signin-wrap"></div>
</div>
</div>
{% endif %}
<div class="container{% if config.authMode == 'google' %} is-hidden{% endif %}" id="app">
<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="duplicate-warning is-hidden" id="duplicate-warning">You've already submitted an order this week. Submitting again will replace your previous order.</div>
{% if has_discount %}
<div class="discount-banner">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 %}</div>
{% endif %}
{% if config.authMode == 'google' %}
<div class="employee-info">
<div id="user-info">
<div class="user-info-row">
<img id="user-avatar" class="user-avatar is-hidden" alt="">
<div class="user-meta">
<div id="user-name" class="user-name"></div>
<div id="user-email" class="user-email"></div>
</div>
<button type="button" id="admin-btn" class="btn btn-primary btn-sm is-hidden">Admin</button>
<button type="button" id="sign-out-btn" class="btn btn-outline btn-sm">Sign out</button>
</div>
</div>
</div>
{% 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>
{% endif %}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
<div class="filters" id="filters"></div>
<template id="meal-card-tpl">
<div class="meal-card" data-meal-index="">
<img class="meal-img is-hidden" alt="" loading="lazy">
<div class="meal-info">
<div class="meal-name"></div>
<div class="meal-desc is-hidden"></div>
<div class="meal-meta"></div>
<div class="meal-tags"></div>
</div>
<div class="qty-control">
<button type="button" data-qty-delta="-1" aria-label="Decrease">&minus;</button>
<input class="qty" type="number" min="0" step="1" value="0">
<button type="button" data-qty-delta="1" aria-label="Increase">+</button>
</div>
</div>
</template>
<div id="meals-list"></div>
<div class="sticky-footer{% if config.authMode == 'google' %} is-hidden{% endif %}" id="sticky-footer">
<div class="inner">
<div>
<span class="total" id="total-display">$0.00</span>
<span class="count-display" id="count-display">0 meals</span>
</div>
<button type="button" class="btn btn-primary submit-btn" id="submit-btn" disabled>Submit Order</button>
</div>
</div>
<div id="admin-panel" class="is-hidden">
<div class="admin-panel">
<div class="admin-header">
<h2>Order Admin</h2>
<div class="admin-toolbar">
<select id="admin-week"></select>
<button type="button" id="admin-download-btn" class="btn btn-primary admin-toolbar-btn" data-admin-action="download-list" disabled>Download order list</button>
<button type="button" id="admin-pdf-btn" class="btn btn-primary admin-toolbar-btn" data-admin-action="download-pdf">Download summary PDF</button>
<button type="button" class="btn btn-outline back-btn" data-admin-action="hide-admin">Back to Menu</button>
</div>
</div>
<div id="admin-stats" class="admin-stats"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Employee</th>
<th>Items</th>
<th>Total</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody id="admin-tbody"></tbody>
</table>
</div>
<div id="admin-empty" class="admin-empty is-hidden">No orders for this week.</div>
</div>
</div>
<div id="success" class="success-msg is-hidden">
<h2>Order submitted!</h2>
<p id="success-detail"></p>
<button type="button" id="success-back-btn" class="btn btn-outline back-btn">Back to Menu</button>
</div>
</div>
<script>
window.CONFIG = {{ config_json|safe }};
</script>
<script>
{{ form_js|safe }}
</script>
<script>
{{ admin_js|safe }}
</script>
</body>
</html>

View file

@ -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 = '<option value="">Select your name</option>';
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();

11
tests/fixtures/config.json vendored Normal file
View file

@ -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
}

60
tests/fixtures/menu.json vendored Normal file
View file

@ -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": [
"<b>tag</b>"
],
"image_url": "",
"is_new": false
},
{
"name": "</script><script>alert(1)</script>",
"price": 9.0,
"calories": "300",
"protein": "10g",
"description": "Hostile script",
"dietary_tags": [],
"image_url": "",
"is_new": false
},
{
"name": "<!--<script>alert(1)</script>-->",
"price": 9.0,
"calories": "300",
"protein": "10g",
"description": "Hostile script comment",
"dietary_tags": [],
"image_url": "",
"is_new": false
}
]
}

294
tests/test_generate_form.py Normal file
View file

@ -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 '<link rel="stylesheet"' not in html
# Only allowed external script is GSI in google mode
local = _render(google=False)
assert "<script src=" not in local
def test_script_order_form_then_admin(self):
html = _render()
form_pos = html.index("FORM_JS_MARKER")
admin_pos = html.index("ADMIN_JS_MARKER")
assert form_pos < admin_pos
def test_single_submit_order_definition(self):
html = _render(google=True)
# One async function submitOrder in the inlined form.js
assert len(re.findall(r"async function submitOrder\s*\(", html)) == 1
def test_no_onclick_admin_data_embedding(self):
html = _render(google=True)
assert 'onclick="admin' not in html
# Static markup must not interpolate runtime data into onclick
assert not re.search(r'onclick="[^"]*\$\{', html)
def test_script_breakout_meal_escaped(self):
html = _render()
assert "</script><script>alert(1)</script>" 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 </script> inside the CONFIG script would terminate early.
assert "</script>" not in blob
# Parsed JSON still contains the hostile meal name.
cfg = json.loads(blob)
assert any(
m["name"] == "</script><script>alert(1)</script>" 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 "</script><script>alert(1)</script>" in names
assert "<!--<script>alert(1)</script>-->" 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"