mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 05:23:13 +00:00
refactor(form): extract Jinja templates and lock form JS in CI (#77)
Some checks are pending
Deploy / deploy (push) Waiting to run
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:
parent
3fe6e74557
commit
216618a862
10 changed files with 2224 additions and 980 deletions
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
425
src/server/templates/admin.js
Normal file
425
src/server/templates/admin.js
Normal 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 = '−';
|
||||
|
||||
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();
|
||||
})();
|
||||
586
src/server/templates/form.css
Normal file
586
src/server/templates/form.css
Normal 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; }
|
||||
153
src/server/templates/form.html.j2
Normal file
153
src/server/templates/form.html.j2
Normal 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">🍽</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">🍽</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 }} · 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">−</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>
|
||||
609
src/server/templates/form.js
Normal file
609
src/server/templates/form.js
Normal 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
11
tests/fixtures/config.json
vendored
Normal 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
60
tests/fixtures/menu.json
vendored
Normal 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
294
tests/test_generate_form.py
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue