mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
21 commits
502e15792e
...
11c7e14ce3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c7e14ce3 | ||
| d5a54b2e78 | |||
| 9f34cb093f | |||
| c0f6316f3f | |||
|
|
9a96babcc6 | ||
| 5ba3f00157 | |||
| 91286fc922 | |||
| e60b8e26dd | |||
| dea398626f | |||
|
|
ac49e62707 | ||
| 0b60d4db55 | |||
|
|
bb1107ad7f | ||
| 2df66fd36f | |||
| fca2ec6044 | |||
| 84cb6251fe | |||
| 58be9ea2f6 | |||
| ae03a227d5 | |||
|
|
216618a862 | ||
|
|
3fe6e74557 | ||
| 043eea1443 | |||
| 2a985c1940 |
10 changed files with 2925 additions and 969 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
493
src/server/templates/admin.js
Normal file
493
src/server/templates/admin.js
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
/* 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;
|
||||
const ADMIN_REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
let currentAdminWeek = '';
|
||||
let editingOrder = null;
|
||||
let editQuantities = {};
|
||||
let lastAdminData = null;
|
||||
let latestAdminRequest = 0;
|
||||
|
||||
function adminHeaders() {
|
||||
const h = {};
|
||||
const cred = window.googleCredential;
|
||||
if (cred) h['Authorization'] = 'Bearer ' + cred;
|
||||
return h;
|
||||
}
|
||||
|
||||
function adminFetch(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS);
|
||||
return fetch(url, { ...options, signal: controller.signal })
|
||||
.finally(() => clearTimeout(timeout));
|
||||
}
|
||||
|
||||
function setAdminActionDisabled(action, disabled) {
|
||||
document.querySelectorAll('[data-admin-action="' + action + '"]').forEach(btn => {
|
||||
btn.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWeekOrders(week) {
|
||||
if (!week) return;
|
||||
const requestId = ++latestAdminRequest;
|
||||
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 = '';
|
||||
|
||||
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (requestId === latestAdminRequest && week === currentAdminWeek) {
|
||||
renderAdminTable(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === latestAdminRequest && week === currentAdminWeek) {
|
||||
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.setAttribute('aria-label', 'Remove one ' + name);
|
||||
minusBtn.innerHTML = '−';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = qty;
|
||||
span.setAttribute('aria-label', 'Quantity of ' + name);
|
||||
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.type = 'button';
|
||||
plusBtn.dataset.adminAction = 'edit-qty';
|
||||
plusBtn.dataset.mealName = name;
|
||||
plusBtn.dataset.delta = '1';
|
||||
plusBtn.setAttribute('aria-label', 'Add one ' + name);
|
||||
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 + (mealCount === 1 ? ' meal' : ' 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;
|
||||
window.FormUtils.clearStatus('admin');
|
||||
adminFetch(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 {
|
||||
window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
}
|
||||
} else if (status === 404) {
|
||||
window.FormUtils.showStatus(
|
||||
'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.',
|
||||
{ tone: 'info', scope: 'admin' }
|
||||
);
|
||||
} else {
|
||||
window.FormUtils.showStatus(
|
||||
'Could not fetch the summary PDF: ' + (data.error || 'unknown error'),
|
||||
{ tone: 'error', scope: 'admin' }
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.FormUtils.showStatus('Could not fetch the summary PDF.', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => { if (btn) btn.disabled = false; });
|
||||
}
|
||||
|
||||
function adminStartEdit(email) {
|
||||
window.FormUtils.clearStatus('admin');
|
||||
const requestedWeek = currentAdminWeek;
|
||||
setAdminActionDisabled('start-edit', true);
|
||||
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (currentAdminWeek !== requestedWeek) return;
|
||||
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(() => {
|
||||
window.FormUtils.showStatus('Failed to load this order for editing.', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => setAdminActionDisabled('start-edit', false));
|
||||
}
|
||||
|
||||
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) {
|
||||
// confirm() retained deliberately for destructive zero-qty → delete guard.
|
||||
if (confirm('All quantities are zero. Delete this order instead?')) {
|
||||
adminDeleteOrder(email, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.FormUtils.clearStatus('admin');
|
||||
setAdminActionDisabled('save-edit', true);
|
||||
adminFetch(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 => {
|
||||
window.FormUtils.showStatus('Failed to update: ' + e.message, {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => setAdminActionDisabled('save-edit', false));
|
||||
}
|
||||
|
||||
function adminDeleteOrder(email, name) {
|
||||
const label = name ? name + ' (' + email + ')' : email;
|
||||
// confirm() retained deliberately for destructive delete guard.
|
||||
if (!confirm('Delete order for ' + label + '?')) return;
|
||||
window.FormUtils.clearStatus('admin');
|
||||
setAdminActionDisabled('delete', true);
|
||||
adminFetch(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 => {
|
||||
window.FormUtils.showStatus('Failed to delete: ' + e.message, {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => setAdminActionDisabled('delete', false));
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
656
src/server/templates/form.css
Normal file
656
src/server/templates/form.css
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
/* 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);
|
||||
--sticky-footer-height: 80px;
|
||||
}
|
||||
|
||||
* { 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(var(--sticky-footer-height) + 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-bar { 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; min-width: 0; }
|
||||
.user-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.user-email {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.meal-desc.is-expanded {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.meal-desc-toggle {
|
||||
display: inline-block;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-brand);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meal-desc-toggle:hover { opacity: 0.85; }
|
||||
|
||||
.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));
|
||||
min-height: calc(var(--sticky-footer-height) + 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: 479px) {
|
||||
.user-bar { flex-wrap: wrap; }
|
||||
.user-meta { flex-basis: calc(100% - 52px); }
|
||||
.user-bar #admin-btn { margin-left: 52px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.btn-chip { min-height: 44px; padding: 10px 16px; }
|
||||
}
|
||||
|
||||
.form-status {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.sticky-footer .form-status {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-panel > .form-status {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.form-status-msg:empty { display: none; }
|
||||
|
||||
.form-status-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-control);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-status-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.form-status-info {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.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; }
|
||||
161
src/server/templates/form.html.j2
Normal file
161
src/server/templates/form.html.j2
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<!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-bar">
|
||||
<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..." aria-label="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" aria-label="Quantity">
|
||||
<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="form-status" id="form-status">
|
||||
<div id="form-status-alert" class="form-status-msg form-status-error" role="alert"></div>
|
||||
<div id="form-status-info" class="form-status-msg form-status-info" role="status"></div>
|
||||
</div>
|
||||
<div class="inner">
|
||||
<div aria-live="polite">
|
||||
<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 class="form-status" id="admin-status">
|
||||
<div id="admin-status-alert" class="form-status-msg form-status-error" role="alert"></div>
|
||||
<div id="admin-status-info" class="form-status-msg form-status-info" role="status"></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 id="success-heading" tabindex="-1">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>
|
||||
723
src/server/templates/form.js
Normal file
723
src/server/templates/form.js
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
/* 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;
|
||||
|
||||
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function clearStatus(scope) {
|
||||
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
|
||||
const alertEl = document.getElementById(prefix + '-alert');
|
||||
const infoEl = document.getElementById(prefix + '-info');
|
||||
if (alertEl) alertEl.textContent = '';
|
||||
if (infoEl) infoEl.textContent = '';
|
||||
}
|
||||
|
||||
function showStatus(message, options) {
|
||||
const opts = options || {};
|
||||
const tone = opts.tone || 'error';
|
||||
const scope = opts.scope || 'form';
|
||||
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
|
||||
const alertEl = document.getElementById(prefix + '-alert');
|
||||
const infoEl = document.getElementById(prefix + '-info');
|
||||
if (!alertEl || !infoEl) return;
|
||||
if (tone === 'info') {
|
||||
alertEl.textContent = '';
|
||||
infoEl.textContent = message || '';
|
||||
} else {
|
||||
infoEl.textContent = '';
|
||||
alertEl.textContent = message || '';
|
||||
}
|
||||
}
|
||||
|
||||
window.FormUtils = { employeePrice, showStatus, clearStatus };
|
||||
|
||||
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(attempt = 0) {
|
||||
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
|
||||
initGoogleAuth();
|
||||
} else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
|
||||
setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
|
||||
} else {
|
||||
const signIn = document.getElementById('g-signin-btn');
|
||||
if (signIn) {
|
||||
signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.';
|
||||
}
|
||||
console.error('Google Identity Services failed to load.');
|
||||
}
|
||||
}
|
||||
|
||||
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 removeDescToggle(card) {
|
||||
const existing = card.querySelector('.meal-desc-toggle');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
function attachDescToggleIfOverflow(card) {
|
||||
const descEl = card.querySelector('.meal-desc');
|
||||
if (!descEl || descEl.classList.contains('is-hidden')) return;
|
||||
removeDescToggle(card);
|
||||
if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'meal-desc-toggle';
|
||||
btn.textContent = 'More';
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.addEventListener('click', () => {
|
||||
const expanded = descEl.classList.toggle('is-expanded');
|
||||
btn.textContent = expanded ? 'Less' : 'More';
|
||||
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
});
|
||||
descEl.after(btn);
|
||||
}
|
||||
|
||||
function scheduleDescToggle(card) {
|
||||
requestAnimationFrame(() => {
|
||||
attachDescToggleIfOverflow(card);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
removeDescToggle(card);
|
||||
descEl.classList.remove('is-expanded');
|
||||
if (meal.description) {
|
||||
descEl.textContent = meal.description;
|
||||
descEl.classList.remove('is-hidden');
|
||||
scheduleDescToggle(card);
|
||||
} 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;
|
||||
qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name);
|
||||
|
||||
const minusBtn = card.querySelector('[data-qty-delta="-1"]');
|
||||
const plusBtn = card.querySelector('[data-qty-delta="1"]');
|
||||
if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name);
|
||||
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name);
|
||||
|
||||
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 setQtyState(index, value) {
|
||||
const next = Math.max(0, parseInt(value, 10) || 0);
|
||||
if (next === 0) delete quantities[index];
|
||||
else quantities[index] = next;
|
||||
}
|
||||
|
||||
function setQty(index, value) {
|
||||
setQtyState(index, value);
|
||||
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);
|
||||
setQtyState(index, input.value);
|
||||
updateTotal();
|
||||
});
|
||||
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() {
|
||||
clearStatus('form');
|
||||
|
||||
if (formClosed && !isAdmin) {
|
||||
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
let successName;
|
||||
|
||||
if (CONFIG.authMode === 'google') {
|
||||
if (!googleCredential || !googleUser) {
|
||||
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
payload = { google_id_token: googleCredential, items: buildItems() };
|
||||
successName = googleUser.name;
|
||||
} else {
|
||||
const nameEl = document.getElementById('emp-name');
|
||||
const emailEl = document.getElementById('emp-email');
|
||||
const name = nameEl.value.trim();
|
||||
const email = emailEl.value.trim();
|
||||
if (!name) {
|
||||
showStatus('Please enter your name.', { tone: 'error', scope: 'form' });
|
||||
nameEl.focus();
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
showStatus('Please enter your email.', { tone: 'error', scope: 'form' });
|
||||
emailEl.focus();
|
||||
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) {}
|
||||
const successHeading = document.getElementById('success-heading');
|
||||
if (successHeading) successHeading.focus();
|
||||
} else {
|
||||
showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
}
|
||||
|
||||
function bindEmpNameChange(select) {
|
||||
if (select.dataset.boundChange === '1') return;
|
||||
select.dataset.boundChange = '1';
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
clearStatus('form');
|
||||
});
|
||||
}
|
||||
|
||||
function populateEmpSelect(select, employees) {
|
||||
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);
|
||||
});
|
||||
bindEmpNameChange(select);
|
||||
}
|
||||
|
||||
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');
|
||||
populateEmpSelect(select, employees);
|
||||
}
|
||||
|
||||
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';
|
||||
const isActive = cat === 'All Meals';
|
||||
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
|
||||
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
btn.textContent = cat;
|
||||
btn.addEventListener('click', () => {
|
||||
filtersEl.querySelectorAll('button').forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
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": "Stir-fried beef tenderloin with onions, tomatoes, and soy sauce, served over french fries with a side of steamed white rice and aji amarillo. A long fixture blurb used to exercise the three-line clamp and the More expand control in accessibility tests.",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
735
tests/test_generate_form.py
Normal file
735
tests/test_generate_form.py
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
"""Structural and Playwright tests for the generated order form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
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 "",
|
||||
)
|
||||
|
||||
|
||||
def _google_credential() -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"name": "Test Admin",
|
||||
"email": "test-admin@example.com",
|
||||
"picture": (
|
||||
"data:image/svg+xml,"
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
||||
),
|
||||
}
|
||||
).encode()
|
||||
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
||||
return f"e30.{encoded}.signature"
|
||||
|
||||
|
||||
def _mock_form_routes(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.route(
|
||||
"**/api/admin/orders",
|
||||
lambda route: route.fulfill(
|
||||
status=403,
|
||||
content_type="application/json",
|
||||
body='{"error":"Forbidden"}',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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_mobile_viewport_and_safe_area_clearance_are_preserved(self):
|
||||
html = _render()
|
||||
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
||||
assert (
|
||||
'content="width=device-width, initial-scale=1.0, viewport-fit=cover"'
|
||||
in html
|
||||
)
|
||||
assert "--sticky-footer-height: 80px;" in css
|
||||
assert (
|
||||
"padding-bottom: calc(var(--sticky-footer-height) + "
|
||||
"env(safe-area-inset-bottom, 0px));"
|
||||
) in css
|
||||
assert (
|
||||
"padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));"
|
||||
) in css
|
||||
assert (
|
||||
"min-height: calc(var(--sticky-footer-height) + "
|
||||
"env(safe-area-inset-bottom, 0px));"
|
||||
) in css
|
||||
|
||||
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_a11y_status_and_live_total_markup(self):
|
||||
html = _render()
|
||||
assert 'id="form-status-alert"' in html
|
||||
assert 'id="form-status-info"' in html
|
||||
assert 'id="admin-status-alert"' in html
|
||||
assert 'id="admin-status-info"' in html
|
||||
assert 'role="alert"' in html
|
||||
assert 'role="status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'id="success-heading"' in html
|
||||
assert 'tabindex="-1"' in html
|
||||
|
||||
def test_no_alert_dialogs_in_form_scripts(self):
|
||||
form_js = (SERVER_DIR / "templates" / "form.js").read_text()
|
||||
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
|
||||
assert "alert(" not in form_js
|
||||
assert "alert(" not in admin_js
|
||||
# confirm() retained for destructive admin delete / zero-qty save.
|
||||
assert "confirm(" in admin_js
|
||||
|
||||
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()
|
||||
_mock_form_routes(page)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
yield page
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
@pytest.mark.parametrize("width", [320, 375, 480])
|
||||
@pytest.mark.parametrize("google", [False, True], ids=["local", "google"])
|
||||
def test_sticky_footer_fits_narrow_viewports(self, tmp_path, width, google):
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
sync_playwright = sync_api.sync_playwright
|
||||
|
||||
html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html"
|
||||
html_path.write_text(_render(google=google, api_key=""))
|
||||
|
||||
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(viewport={"width": width, "height": 800})
|
||||
page.add_init_script(
|
||||
"""window.google = {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize() {},
|
||||
renderButton() {},
|
||||
disableAutoSelect() {},
|
||||
},
|
||||
},
|
||||
};"""
|
||||
)
|
||||
page.route(
|
||||
"https://accounts.google.com/gsi/client",
|
||||
lambda route: route.abort(),
|
||||
)
|
||||
_mock_form_routes(page)
|
||||
|
||||
page.goto(html_path.as_uri(), wait_until="domcontentloaded")
|
||||
page.wait_for_selector('[data-meal-index="0"]', state="attached")
|
||||
if google:
|
||||
page.evaluate(
|
||||
"(credential) => handleCredentialResponse({ credential })",
|
||||
_google_credential(),
|
||||
)
|
||||
page.wait_for_selector('[data-meal-index="0"]', state="visible")
|
||||
|
||||
first_increment = page.locator(
|
||||
'[data-meal-index="0"] [data-qty-delta="1"]'
|
||||
)
|
||||
first_increment.click()
|
||||
first_increment.click()
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === "
|
||||
"'2 meals'"
|
||||
)
|
||||
page.evaluate("() => document.fonts.ready")
|
||||
|
||||
total = page.locator("#total-display")
|
||||
count = page.locator("#count-display")
|
||||
submit = page.locator("#submit-btn")
|
||||
assert total.inner_text() == "$10.80"
|
||||
assert count.inner_text() == "2 meals"
|
||||
assert total.is_visible()
|
||||
assert count.is_visible()
|
||||
assert submit.is_visible()
|
||||
assert submit.is_enabled()
|
||||
submit.focus()
|
||||
assert submit.evaluate(
|
||||
"(element) => element === document.activeElement"
|
||||
)
|
||||
|
||||
page.evaluate(
|
||||
"() => window.scrollTo(0, document.documentElement.scrollHeight)"
|
||||
)
|
||||
layout = page.evaluate(
|
||||
"""() => {
|
||||
const rect = (selector) => {
|
||||
const box = document.querySelector(selector)
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
};
|
||||
};
|
||||
const footer = document.querySelector('#sticky-footer');
|
||||
return {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
footer: {
|
||||
...rect('#sticky-footer'),
|
||||
position: getComputedStyle(footer).position,
|
||||
},
|
||||
total: rect('#total-display'),
|
||||
count: rect('#count-display'),
|
||||
submit: rect('#submit-btn'),
|
||||
lastCard: rect('#meals-list .meal-card:last-child'),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
assert layout["scrollWidth"] == layout["clientWidth"]
|
||||
for selector in ("footer", "total", "count", "submit"):
|
||||
assert layout[selector]["left"] >= 0
|
||||
assert layout[selector]["right"] <= layout["clientWidth"]
|
||||
assert layout["footer"]["position"] == "fixed"
|
||||
assert layout["footer"]["top"] >= 0
|
||||
assert layout["footer"]["bottom"] <= layout["viewportHeight"]
|
||||
assert layout["lastCard"]["bottom"] <= layout["footer"]["top"]
|
||||
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_labels_present_after_init(self, browser_page):
|
||||
page = browser_page
|
||||
card = page.locator('[data-meal-index="0"]')
|
||||
assert (
|
||||
card.locator('[data-qty-delta="-1"]').get_attribute("aria-label")
|
||||
== "Remove one Chicken Bowl"
|
||||
)
|
||||
assert (
|
||||
card.locator('[data-qty-delta="1"]').get_attribute("aria-label")
|
||||
== "Add one Chicken Bowl"
|
||||
)
|
||||
assert (
|
||||
card.locator(".qty").get_attribute("aria-label")
|
||||
== "Quantity of Chicken Bowl"
|
||||
)
|
||||
|
||||
def test_filter_chip_aria_pressed(self, browser_page):
|
||||
page = browser_page
|
||||
all_btn = page.locator("#filters button", has_text="All Meals")
|
||||
assert all_btn.get_attribute("aria-pressed") == "true"
|
||||
other = page.locator("#filters button").nth(1)
|
||||
other_label = other.inner_text()
|
||||
other.click()
|
||||
page.wait_for_function(
|
||||
"""(label) => {
|
||||
const active = document.querySelector('#filters button.active');
|
||||
return active && active.textContent === label
|
||||
&& active.getAttribute('aria-pressed') === 'true';
|
||||
}""",
|
||||
arg=other_label,
|
||||
)
|
||||
assert all_btn.get_attribute("aria-pressed") == "false"
|
||||
assert other.get_attribute("aria-pressed") == "true"
|
||||
all_btn.click()
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const active = document.querySelector('#filters button.active');
|
||||
return active && active.textContent === 'All Meals'
|
||||
&& active.getAttribute('aria-pressed') === 'true';
|
||||
}"""
|
||||
)
|
||||
|
||||
def test_desc_more_only_when_overflow(self, browser_page):
|
||||
page = browser_page
|
||||
# Ensure All Meals filter so both short and long cards are visible.
|
||||
page.locator("#filters button", has_text="All Meals").click()
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
page.wait_for_selector('[data-meal-index="1"]')
|
||||
short_card = page.locator('[data-meal-index="0"]')
|
||||
long_card = page.locator('[data-meal-index="1"]')
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle');
|
||||
return !!long;
|
||||
}"""
|
||||
)
|
||||
assert short_card.locator(".meal-desc-toggle").count() == 0
|
||||
toggle = long_card.locator(".meal-desc-toggle")
|
||||
assert toggle.inner_text() == "More"
|
||||
assert toggle.get_attribute("aria-expanded") == "false"
|
||||
toggle.click()
|
||||
assert toggle.inner_text() == "Less"
|
||||
assert toggle.get_attribute("aria-expanded") == "true"
|
||||
assert "is-expanded" in (
|
||||
long_card.locator(".meal-desc").get_attribute("class") or ""
|
||||
)
|
||||
|
||||
def test_submit_failure_lands_in_alert_region(self, browser_page):
|
||||
page = browser_page
|
||||
page.route(
|
||||
"**/api/submit-order**",
|
||||
lambda route: route.fulfill(
|
||||
status=500,
|
||||
content_type="application/json",
|
||||
body='{"error":"Simulated submit failure"}',
|
||||
),
|
||||
)
|
||||
page.locator("#emp-name").select_option(label="Ada Lovelace")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
|
||||
input.value = '0';
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}"""
|
||||
)
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === '0 meals'"
|
||||
)
|
||||
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === '1 meal'"
|
||||
)
|
||||
page.locator("#submit-btn").click()
|
||||
page.wait_for_function(
|
||||
"""() => document.getElementById('form-status-alert').textContent
|
||||
=== 'Simulated submit failure'"""
|
||||
)
|
||||
assert (
|
||||
page.locator("#form-status-alert").inner_text()
|
||||
== "Simulated submit failure"
|
||||
)
|
||||
assert page.locator("#form-status-info").inner_text() == ""
|
||||
page.unroute("**/api/submit-order**")
|
||||
|
||||
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"
|
||||
|
||||
@pytest.mark.parametrize("width", [320, 375, 479])
|
||||
def test_local_roster_has_no_horizontal_overflow(self, browser_page, width):
|
||||
page = browser_page
|
||||
page.set_viewport_size({"width": width, "height": 800})
|
||||
dimensions = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
||||
|
||||
|
||||
class TestGenerateFormGooglePlaywright:
|
||||
@pytest.fixture(scope="class")
|
||||
@classmethod
|
||||
def signed_in_admin_page(cls, tmp_path_factory):
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"playwright package is required; install requirements.txt"
|
||||
) from exc
|
||||
|
||||
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
|
||||
out.write_text(_render(google=True, api_key=""))
|
||||
|
||||
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.add_init_script(
|
||||
"""window.google = {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize() {},
|
||||
renderButton() {},
|
||||
disableAutoSelect() {},
|
||||
},
|
||||
},
|
||||
};"""
|
||||
)
|
||||
page.route(
|
||||
"https://accounts.google.com/gsi/client",
|
||||
lambda route: route.abort(),
|
||||
)
|
||||
|
||||
def handle_api(route):
|
||||
if "/api/form-status/" in route.request.url:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"status":"open"}',
|
||||
)
|
||||
elif "/api/admin/orders" in route.request.url:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"weeks":["2026-W30"]}',
|
||||
)
|
||||
else:
|
||||
route.fulfill(
|
||||
status=404,
|
||||
content_type="application/json",
|
||||
body="{}",
|
||||
)
|
||||
|
||||
page.route("https://api.example.com/**", handle_api)
|
||||
page.goto(out.as_uri(), wait_until="domcontentloaded")
|
||||
page.evaluate(
|
||||
"(credential) => handleCredentialResponse({ credential })",
|
||||
_google_credential(),
|
||||
)
|
||||
page.locator("#admin-btn").wait_for(state="visible")
|
||||
yield page
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
@pytest.mark.parametrize("width", [320, 375])
|
||||
def test_signed_in_admin_user_bar_fits_phone_width(
|
||||
self, signed_in_admin_page, width
|
||||
):
|
||||
page = signed_in_admin_page
|
||||
page.set_viewport_size({"width": width, "height": 800})
|
||||
|
||||
for selector in (
|
||||
"#user-avatar",
|
||||
"#user-name",
|
||||
"#user-email",
|
||||
"#admin-btn",
|
||||
"#sign-out-btn",
|
||||
):
|
||||
locator = page.locator(selector)
|
||||
assert locator.is_visible()
|
||||
bounds = locator.evaluate(
|
||||
"""element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
assert bounds["left"] >= 0
|
||||
assert bounds["top"] >= 0
|
||||
assert bounds["right"] <= bounds["viewportWidth"]
|
||||
assert bounds["bottom"] <= bounds["viewportHeight"]
|
||||
|
||||
for selector in ("#user-name", "#user-email"):
|
||||
assert page.locator(selector).evaluate(
|
||||
"""element =>
|
||||
element.scrollWidth <= element.clientWidth &&
|
||||
element.scrollHeight <= element.clientHeight"""
|
||||
)
|
||||
assert page.locator("#admin-btn").is_enabled()
|
||||
assert page.locator("#sign-out-btn").is_enabled()
|
||||
|
||||
dimensions = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
||||
|
||||
def test_user_bar_stays_single_row_at_480(self, signed_in_admin_page):
|
||||
page = signed_in_admin_page
|
||||
page.set_viewport_size({"width": 480, "height": 800})
|
||||
assert (
|
||||
page.locator(".user-bar").evaluate(
|
||||
"(element) => getComputedStyle(element).flexWrap"
|
||||
)
|
||||
== "nowrap"
|
||||
)
|
||||
dimensions = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
||||
|
||||
def test_user_bar_wraps_below_shared_mobile_breakpoint(self, signed_in_admin_page):
|
||||
page = signed_in_admin_page
|
||||
page.set_viewport_size({"width": 479, "height": 800})
|
||||
assert (
|
||||
page.locator(".user-bar").evaluate(
|
||||
"(element) => getComputedStyle(element).flexWrap"
|
||||
)
|
||||
== "wrap"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue