mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
2 commits
149cc9a0be
...
02fe514712
| Author | SHA1 | Date | |
|---|---|---|---|
| 02fe514712 | |||
| 57a6412811 |
8 changed files with 220 additions and 873 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -7,7 +7,7 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
lint:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
|
||||
with:
|
||||
run-tests: false
|
||||
|
|
@ -16,11 +16,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.x
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
|
|
|||
|
|
@ -38,13 +38,8 @@ def latest_menu() -> dict:
|
|||
|
||||
|
||||
def _json_for_script(value) -> str:
|
||||
"""Serialize for embedding in a <script> tag; escape < and JS line separators."""
|
||||
return (
|
||||
json.dumps(value, separators=(",", ":"))
|
||||
.replace("<", "\\u003c")
|
||||
.replace("\u2028", "\\u2028")
|
||||
.replace("\u2029", "\\u2029")
|
||||
)
|
||||
"""Serialize for embedding in a <script> tag; escape </ to avoid early close."""
|
||||
return json.dumps(value, separators=(",", ":")).replace("</", "<\\/")
|
||||
|
||||
|
||||
def generate_form(
|
||||
|
|
@ -97,9 +92,9 @@ def generate_form(
|
|||
return template.render(
|
||||
config=form_config,
|
||||
config_json=_json_for_script(form_config),
|
||||
css=(TEMPLATES_DIR / "form.css").read_text(encoding="utf-8"),
|
||||
form_js=(TEMPLATES_DIR / "form.js").read_text(encoding="utf-8"),
|
||||
admin_js=(TEMPLATES_DIR / "admin.js").read_text(encoding="utf-8"),
|
||||
css=(TEMPLATES_DIR / "form.css").read_text(),
|
||||
form_js=(TEMPLATES_DIR / "form.js").read_text(),
|
||||
admin_js=(TEMPLATES_DIR / "admin.js").read_text(),
|
||||
week=week,
|
||||
scraped_at=scraped_at,
|
||||
deadline=deadline,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
/* 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;
|
||||
const ADMIN_API_URL = window.ADMIN_URL;
|
||||
const ADMIN_SUMMARY_PDF_URL = window.ADMIN_PDF_URL;
|
||||
const ADMIN_MEALS = window.MEALS;
|
||||
const ADMIN_WEEK = window.WEEK;
|
||||
|
||||
let currentAdminWeek = '';
|
||||
let editingOrder = null;
|
||||
let editQuantities = {};
|
||||
let lastAdminData = null;
|
||||
let latestAdminRequest = 0;
|
||||
|
||||
function showAdminStatus(message, tone = 'error') {
|
||||
const alertEl = document.getElementById('admin-alert');
|
||||
const statusEl = document.getElementById('admin-status');
|
||||
if (!alertEl || !statusEl) return;
|
||||
alertEl.textContent = tone === 'error' ? message : '';
|
||||
statusEl.textContent = tone === 'error' ? '' : message;
|
||||
}
|
||||
|
||||
function clearAdminStatus() {
|
||||
showAdminStatus('', 'status');
|
||||
}
|
||||
|
||||
function adminHeaders() {
|
||||
const h = {};
|
||||
|
|
@ -19,23 +28,9 @@ function adminHeaders() {
|
|||
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;
|
||||
window.FormUtils.clearStatus('admin');
|
||||
clearAdminStatus();
|
||||
currentAdminWeek = week;
|
||||
editingOrder = null;
|
||||
editQuantities = {};
|
||||
|
|
@ -46,25 +41,13 @@ function loadWeekOrders(week) {
|
|||
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);
|
||||
}
|
||||
})
|
||||
fetch(ADMIN_API_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => renderAdminTable(data))
|
||||
.catch(() => {
|
||||
if (requestId === latestAdminRequest && week === currentAdminWeek) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
|
||||
cards.innerHTML = '';
|
||||
window.FormUtils.showStatus('Failed to load orders.', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
}
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
|
||||
cards.innerHTML = '';
|
||||
showAdminStatus('Failed to load orders.');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -79,8 +62,8 @@ function buildEditQtyRow(name, qty) {
|
|||
minusBtn.dataset.adminAction = 'edit-qty';
|
||||
minusBtn.dataset.mealName = name;
|
||||
minusBtn.dataset.delta = '-1';
|
||||
minusBtn.setAttribute('aria-label', 'Remove one ' + name);
|
||||
minusBtn.innerHTML = '−';
|
||||
minusBtn.setAttribute('aria-label', 'Remove one ' + name);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = qty;
|
||||
|
|
@ -91,8 +74,8 @@ function buildEditQtyRow(name, qty) {
|
|||
plusBtn.dataset.adminAction = 'edit-qty';
|
||||
plusBtn.dataset.mealName = name;
|
||||
plusBtn.dataset.delta = '1';
|
||||
plusBtn.setAttribute('aria-label', 'Add one ' + name);
|
||||
plusBtn.textContent = '+';
|
||||
plusBtn.setAttribute('aria-label', 'Add one ' + name);
|
||||
|
||||
wrap.appendChild(minusBtn);
|
||||
wrap.appendChild(span);
|
||||
|
|
@ -114,9 +97,8 @@ function buildAddItemSelect(availableMeals) {
|
|||
|
||||
availableMeals.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
const price = m.price || 0;
|
||||
opt.value = m.name;
|
||||
opt.textContent = m.name + ' ($' + price.toFixed(2) + ')';
|
||||
opt.textContent = m.name + ' ($' + m.price.toFixed(2) + ')';
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
|
|
@ -149,10 +131,10 @@ function buildEditActions(email) {
|
|||
return wrap;
|
||||
}
|
||||
|
||||
function buildViewActions(email, name) {
|
||||
function buildViewActions(idx, email, name) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'admin-actions';
|
||||
const editBtn = buildActionButton('Edit', 'start-edit', { email });
|
||||
const editBtn = buildActionButton('Edit', 'start-edit', { index: String(idx), email });
|
||||
editBtn.className = 'btn btn-sm btn-edit';
|
||||
const deleteBtn = buildActionButton('Delete', 'delete', { email, name });
|
||||
deleteBtn.className = 'btn btn-sm btn-delete';
|
||||
|
|
@ -175,10 +157,10 @@ function formatSubmittedAt(submittedAt) {
|
|||
|
||||
function editTotal() {
|
||||
const menuPrices = {};
|
||||
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
|
||||
ADMIN_MEALS.forEach(m => { menuPrices[m.name] = m.price; });
|
||||
return Object.keys(editQuantities).reduce((sum, name) => {
|
||||
const price = menuPrices[name] || 0;
|
||||
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
|
||||
return sum + price * (editQuantities[name] || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +169,7 @@ function populateOrderItems(container, order, isEditing) {
|
|||
Object.keys(editQuantities).forEach(name => {
|
||||
container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
|
||||
});
|
||||
const availableMeals = MEALS.filter(
|
||||
const availableMeals = ADMIN_MEALS.filter(
|
||||
m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)
|
||||
);
|
||||
if (availableMeals.length > 0) {
|
||||
|
|
@ -209,7 +191,7 @@ function populateOrderItems(container, order, isEditing) {
|
|||
return '$' + order.total.toFixed(2);
|
||||
}
|
||||
|
||||
function buildAdminCard(order) {
|
||||
function buildAdminCard(order, idx) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'admin-order-card';
|
||||
card.dataset.email = order.employee_email;
|
||||
|
|
@ -250,7 +232,7 @@ function buildAdminCard(order) {
|
|||
footer.appendChild(
|
||||
isEditing
|
||||
? buildEditActions(order.employee_email)
|
||||
: buildViewActions(order.employee_email, order.employee_name)
|
||||
: buildViewActions(idx, order.employee_email, order.employee_name)
|
||||
);
|
||||
|
||||
card.appendChild(header);
|
||||
|
|
@ -262,8 +244,8 @@ function buildAdminCard(order) {
|
|||
function renderAdminCards(data) {
|
||||
const cards = document.getElementById('admin-cards');
|
||||
cards.innerHTML = '';
|
||||
(data.orders || []).forEach(order => {
|
||||
cards.appendChild(buildAdminCard(order));
|
||||
(data.orders || []).forEach((order, idx) => {
|
||||
cards.appendChild(buildAdminCard(order, idx));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +306,7 @@ function renderAdminTable(data) {
|
|||
itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
|
||||
itemsTd.appendChild(document.createElement('br'));
|
||||
});
|
||||
const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name));
|
||||
const availableMeals = ADMIN_MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name));
|
||||
if (availableMeals.length > 0) {
|
||||
itemsTd.appendChild(buildAddItemSelect(availableMeals));
|
||||
}
|
||||
|
|
@ -357,7 +339,7 @@ function renderAdminTable(data) {
|
|||
if (isEditing) {
|
||||
actionsTd.appendChild(buildEditActions(order.employee_email));
|
||||
} else {
|
||||
actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name));
|
||||
actionsTd.appendChild(buildViewActions(idx, order.employee_email, order.employee_name));
|
||||
}
|
||||
|
||||
tr.appendChild(totalTd);
|
||||
|
|
@ -372,7 +354,7 @@ function renderAdminTable(data) {
|
|||
const t1 = document.createElement('td');
|
||||
t1.textContent = 'Total';
|
||||
const t2 = document.createElement('td');
|
||||
t2.textContent = mealCount + (mealCount === 1 ? ' meal' : ' meals');
|
||||
t2.textContent = mealCount + ' meals';
|
||||
const t3 = document.createElement('td');
|
||||
t3.className = 'total-cell';
|
||||
t3.textContent = '$' + data.grand_total.toFixed(2);
|
||||
|
|
@ -396,12 +378,11 @@ function downloadOrderList() {
|
|||
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;
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
};
|
||||
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 week = data.week || currentAdminWeek || ADMIN_WEEK;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
|
@ -414,11 +395,11 @@ function downloadOrderList() {
|
|||
}
|
||||
|
||||
function downloadSummaryPdf() {
|
||||
const week = currentAdminWeek || WEEK;
|
||||
clearAdminStatus();
|
||||
const week = currentAdminWeek || ADMIN_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() })
|
||||
fetch(ADMIN_SUMMARY_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) {
|
||||
|
|
@ -426,38 +407,25 @@ function downloadSummaryPdf() {
|
|||
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',
|
||||
});
|
||||
showAdminStatus('Could not fetch the summary PDF: invalid URL');
|
||||
}
|
||||
} 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' }
|
||||
showAdminStatus(
|
||||
'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.'
|
||||
);
|
||||
} else {
|
||||
window.FormUtils.showStatus(
|
||||
'Could not fetch the summary PDF: ' + (data.error || 'unknown error'),
|
||||
{ tone: 'error', scope: 'admin' }
|
||||
);
|
||||
showAdminStatus('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.FormUtils.showStatus('Could not fetch the summary PDF.', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.catch(() => showAdminStatus('Could not fetch the summary PDF.'))
|
||||
.finally(() => { if (btn) btn.disabled = false; });
|
||||
}
|
||||
|
||||
function adminStartEdit(email) {
|
||||
window.FormUtils.clearStatus('admin');
|
||||
setAdminActionDisabled('start-edit', true);
|
||||
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
|
||||
function adminStartEdit(idx, email) {
|
||||
clearAdminStatus();
|
||||
fetch(ADMIN_API_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
if (!r.ok) throw new Error('Could not load this order.');
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
|
|
@ -469,13 +437,7 @@ function adminStartEdit(email) {
|
|||
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));
|
||||
.catch(e => showAdminStatus(e.message || 'Could not load this order.'));
|
||||
}
|
||||
|
||||
function adminCancelEdit() {
|
||||
|
|
@ -497,53 +459,37 @@ function adminAddItem(name) {
|
|||
}
|
||||
|
||||
function adminSaveEdit(email) {
|
||||
clearAdminStatus();
|
||||
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, {
|
||||
fetch(ADMIN_API_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));
|
||||
.catch(e => showAdminStatus('Failed to update: ' + e.message));
|
||||
}
|
||||
|
||||
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), {
|
||||
clearAdminStatus();
|
||||
fetch(ADMIN_API_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));
|
||||
.catch(e => showAdminStatus('Failed to delete: ' + e.message));
|
||||
}
|
||||
|
||||
function handleAdminPanelClick(e) {
|
||||
|
|
@ -551,7 +497,7 @@ function handleAdminPanelClick(e) {
|
|||
if (btn) {
|
||||
const action = btn.dataset.adminAction;
|
||||
if (action === 'start-edit') {
|
||||
adminStartEdit(btn.dataset.email);
|
||||
adminStartEdit(parseInt(btn.dataset.index, 10), btn.dataset.email);
|
||||
} else if (action === 'delete') {
|
||||
adminDeleteOrder(btn.dataset.email, btn.dataset.name || '');
|
||||
} else if (action === 'save-edit') {
|
||||
|
|
@ -591,4 +537,3 @@ function adminInit() {
|
|||
}
|
||||
|
||||
adminInit();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
--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; }
|
||||
|
|
@ -15,7 +14,7 @@ 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));
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.is-hidden { display: none !important; }
|
||||
|
|
@ -76,7 +75,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-bar { display: flex; align-items: center; gap: 12px; }
|
||||
.user-info-row { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
|
|
@ -88,13 +87,9 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
|
||||
.user-avatar:not(.is-hidden) { display: block; }
|
||||
|
||||
.user-meta { flex: 1; min-width: 0; }
|
||||
.user-meta { flex: 1; }
|
||||
.user-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.user-email {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.user-email { font-size: 0.85rem; color: var(--color-muted); }
|
||||
.count-display { font-size: 0.8rem; color: var(--color-muted); margin-left: 8px; }
|
||||
.g-signin-wrap { display: flex; justify-content: center; }
|
||||
.admin-muted { font-size: 0.8rem; color: #9ca3af; }
|
||||
|
|
@ -152,28 +147,6 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
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;
|
||||
|
|
@ -217,7 +190,6 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
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;
|
||||
}
|
||||
|
|
@ -366,16 +338,10 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
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);
|
||||
grid-template-columns: 80px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.qty-control {
|
||||
|
|
@ -389,46 +355,10 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
.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; }
|
||||
.admin-order-card .btn,
|
||||
.admin-add-item select { min-height: 44px; }
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -542,7 +472,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; }
|
||||
.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; margin-top: 0; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-toolbar { width: 100%; }
|
||||
|
|
@ -552,6 +482,17 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
.admin-stats { font-size: 0.9rem; color: var(--color-muted); margin-bottom: 16px; }
|
||||
.admin-stats strong { color: #1d1d1f; }
|
||||
|
||||
.admin-status-regions:empty { display: none; }
|
||||
|
||||
.admin-status {
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-status:empty { display: none; }
|
||||
.admin-status-error { color: #991b1b; }
|
||||
|
||||
.admin-table-wrap { overflow-x: auto; }
|
||||
|
||||
.admin-table {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
{% if config.authMode == 'google' %}
|
||||
<div class="employee-info">
|
||||
<div id="user-info">
|
||||
<div class="user-bar">
|
||||
<div class="user-info-row">
|
||||
<img id="user-avatar" class="user-avatar is-hidden" alt="">
|
||||
<div class="user-meta">
|
||||
<div id="user-name" class="user-name"></div>
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<input type="text" class="search-bar" id="search" placeholder="Search meals..." aria-label="Search meals">
|
||||
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
|
||||
|
||||
<div class="filters" id="filters"></div>
|
||||
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
</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">
|
||||
<input class="qty" type="number" min="0" step="1" value="0">
|
||||
<button type="button" data-qty-delta="1" aria-label="Increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,12 +94,8 @@
|
|||
<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">
|
||||
<div>
|
||||
<span class="total" id="total-display">$0.00</span>
|
||||
<span class="count-display" id="count-display">0 meals</span>
|
||||
</div>
|
||||
|
|
@ -118,11 +114,11 @@
|
|||
<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-status-regions">
|
||||
<div id="admin-alert" class="admin-status admin-status-error" role="alert"></div>
|
||||
<div id="admin-status" class="admin-status" role="status"></div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
|
|
@ -143,7 +139,7 @@
|
|||
</div>
|
||||
|
||||
<div id="success" class="success-msg is-hidden">
|
||||
<h2 id="success-heading" tabindex="-1">Order submitted!</h2>
|
||||
<h2>Order submitted!</h2>
|
||||
<p id="success-detail"></p>
|
||||
<button type="button" id="success-back-btn" class="btn btn-outline back-btn">Back to Menu</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,13 +26,20 @@ 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 escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function employeePrice(retail) {
|
||||
if (!retail) return 0;
|
||||
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
|
||||
|
|
@ -40,32 +47,7 @@ function employeePrice(retail) {
|
|||
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 };
|
||||
window.FormUtils = { escapeHtml, employeePrice };
|
||||
|
||||
function syncGoogleCredential() {
|
||||
window.googleCredential = googleCredential;
|
||||
|
|
@ -79,17 +61,11 @@ function buildItems() {
|
|||
}));
|
||||
}
|
||||
|
||||
function waitForGoogleAuth(attempt = 0) {
|
||||
function waitForGoogleAuth() {
|
||||
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.');
|
||||
setTimeout(waitForGoogleAuth, 50);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +103,7 @@ function handleCredentialResponse(response) {
|
|||
|
||||
updateTotal();
|
||||
checkDuplicateOrder();
|
||||
checkAdmin().then(() => {
|
||||
if (formClosed && !isAdmin) showClosedOverlay();
|
||||
});
|
||||
checkAdmin();
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
|
|
@ -209,36 +183,6 @@ function buildMealTagsEl(meal) {
|
|||
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);
|
||||
|
|
@ -258,12 +202,9 @@ function updateMealCard(card, meal, i) {
|
|||
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');
|
||||
|
|
@ -279,12 +220,6 @@ function updateMealCard(card, meal, i) {
|
|||
|
||||
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; });
|
||||
|
|
@ -322,17 +257,13 @@ function updateMealCardQty(index) {
|
|||
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;
|
||||
if (qtyInput) qtyInput.value = qty;
|
||||
}
|
||||
|
||||
function setQty(index, value) {
|
||||
setQtyState(index, value);
|
||||
const next = Math.max(0, parseInt(value, 10) || 0);
|
||||
if (next === 0) delete quantities[index];
|
||||
else quantities[index] = next;
|
||||
updateMealCardQty(index);
|
||||
updateTotal();
|
||||
}
|
||||
|
|
@ -355,21 +286,21 @@ function bindMealsListDelegation() {
|
|||
const delta = parseInt(btn.dataset.qtyDelta, 10);
|
||||
changeQty(index, delta);
|
||||
});
|
||||
list.addEventListener('change', (e) => {
|
||||
const input = e.target.closest('.qty');
|
||||
if (!input) return;
|
||||
const card = input.closest('[data-meal-index]');
|
||||
if (!card) return;
|
||||
const index = parseInt(card.dataset.mealIndex, 10);
|
||||
setQty(index, input.value);
|
||||
});
|
||||
list.addEventListener('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));
|
||||
setQty(index, input.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -387,38 +318,20 @@ function updateTotal() {
|
|||
}
|
||||
|
||||
async function submitOrder() {
|
||||
clearStatus('form');
|
||||
|
||||
if (formClosed && !isAdmin) {
|
||||
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
|
||||
|
||||
let payload;
|
||||
let successName;
|
||||
|
||||
if (CONFIG.authMode === 'google') {
|
||||
if (!googleCredential || !googleUser) {
|
||||
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); 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;
|
||||
}
|
||||
const name = document.getElementById('emp-name').value.trim();
|
||||
const email = document.getElementById('emp-email').value.trim();
|
||||
if (!name) { alert('Please enter your name.'); return; }
|
||||
if (!email) { alert('Please enter your email.'); return; }
|
||||
payload = { employee_name: name, employee_email: email, items: buildItems() };
|
||||
successName = name;
|
||||
}
|
||||
|
|
@ -446,64 +359,53 @@ async function submitOrder() {
|
|||
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' });
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
|
||||
alert('Failed to submit. Check your connection and try again.');
|
||||
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);
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
(data.employees || ROSTER).forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
});
|
||||
} catch (e) {
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
ROSTER.forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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; });
|
||||
document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = true; });
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
if (formStatusData) startCountdown(formStatusData);
|
||||
|
|
@ -533,10 +435,10 @@ function adminBypassClosed() {
|
|||
}
|
||||
|
||||
function checkAdmin() {
|
||||
if (!ADMIN_URL) return Promise.resolve();
|
||||
if (!ADMIN_URL) return;
|
||||
const headers = {};
|
||||
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential;
|
||||
return fetch(ADMIN_URL, { headers })
|
||||
fetch(ADMIN_URL, { headers })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data && data.weeks) {
|
||||
|
|
@ -618,8 +520,7 @@ function getEasternOffsetMs(date) {
|
|||
function startCountdown(data) {
|
||||
if (data && data.reopen_at) reopenAtMs = data.reopen_at * 1000;
|
||||
updateCountdown();
|
||||
if (countdownTimer !== null) clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(updateCountdown, 1000);
|
||||
setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
|
|
@ -660,7 +561,8 @@ async function checkFormStatus() {
|
|||
formStatusData = data;
|
||||
if (isAdmin) {
|
||||
adminBypassClosed();
|
||||
} else if (CONFIG.authMode !== 'google' || googleCredential) {
|
||||
} else if (CONFIG.authMode === 'google') {
|
||||
} else {
|
||||
showClosedOverlay();
|
||||
}
|
||||
}
|
||||
|
|
@ -683,17 +585,11 @@ function init() {
|
|||
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.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : '');
|
||||
btn.textContent = cat;
|
||||
btn.addEventListener('click', () => {
|
||||
filtersEl.querySelectorAll('button').forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
renderMeals();
|
||||
});
|
||||
filtersEl.appendChild(btn);
|
||||
|
|
|
|||
14
tests/fixtures/menu.json
vendored
14
tests/fixtures/menu.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"scraped_at": "2026-07-27T12:00:00+00:00",
|
||||
"meal_count": 5,
|
||||
"meal_count": 4,
|
||||
"meals": [
|
||||
{
|
||||
"name": "Chicken Bowl",
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"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.",
|
||||
"description": "With onions",
|
||||
"dietary_tags": [],
|
||||
"image_url": "",
|
||||
"is_new": true
|
||||
|
|
@ -45,16 +45,6 @@
|
|||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -67,42 +66,6 @@ def _render(*, google: bool = False, api_key: str = "test-key") -> str:
|
|||
)
|
||||
|
||||
|
||||
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"}]}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateFormStructural:
|
||||
def test_local_and_google_render(self):
|
||||
local = _render(google=False)
|
||||
|
|
@ -161,26 +124,6 @@ class TestGenerateFormStructural:
|
|||
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")
|
||||
|
|
@ -195,6 +138,7 @@ class TestGenerateFormStructural:
|
|||
def test_no_onclick_admin_data_embedding(self):
|
||||
html = _render(google=True)
|
||||
assert 'onclick="admin' not in html
|
||||
assert 'onclick="admin' not in html
|
||||
# Static markup must not interpolate runtime data into onclick
|
||||
assert not re.search(r'onclick="[^"]*\$\{', html)
|
||||
|
||||
|
|
@ -204,12 +148,15 @@ class TestGenerateFormStructural:
|
|||
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
|
||||
|
||||
assert 'id="admin-cards"' in html
|
||||
assert 'id="admin-status-alert"' in html
|
||||
assert 'id="admin-status-info"' in html
|
||||
assert 'id="admin-alert"' in html
|
||||
assert 'role="alert"' in html
|
||||
assert 'id="admin-status"' in html
|
||||
assert 'role="status"' in html
|
||||
assert "@media (max-width: 767px)" in css
|
||||
assert ".admin-table-wrap { display: none; }" in css
|
||||
assert "buildAdminCard" in admin_js
|
||||
assert "dataset.adminAction" in admin_js
|
||||
assert "alert(" not in admin_js
|
||||
assert "confirm(" in admin_js
|
||||
|
||||
def test_script_breakout_meal_escaped(self):
|
||||
|
|
@ -220,6 +167,7 @@ class TestGenerateFormStructural:
|
|||
blob = cfg_match.group(1)
|
||||
# Literal </script> inside the CONFIG script would terminate early.
|
||||
assert "</script>" not in blob
|
||||
assert "<\\/script><script>alert(1)<\\/script>" in blob
|
||||
# Parsed JSON still contains the hostile meal name.
|
||||
cfg = json.loads(blob)
|
||||
assert any(
|
||||
|
|
@ -232,7 +180,6 @@ class TestGenerateFormStructural:
|
|||
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()
|
||||
|
|
@ -241,26 +188,6 @@ class TestGenerateFormStructural:
|
|||
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
|
||||
|
|
@ -278,10 +205,14 @@ class TestGenerateFormStructural:
|
|||
|
||||
|
||||
class TestGenerateFormPlaywright:
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="class")
|
||||
def browser_page(self, tmp_path_factory):
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
sync_playwright = sync_api.sync_playwright
|
||||
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
|
||||
|
||||
html = _render(google=False, api_key="")
|
||||
# Stub status/roster so the page does not hang on network; roster fallback
|
||||
|
|
@ -297,122 +228,33 @@ class TestGenerateFormPlaywright:
|
|||
"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,
|
||||
};
|
||||
};
|
||||
return {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
footer: rect('#sticky-footer'),
|
||||
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["lastCard"]["bottom"] <= layout["footer"]["top"]
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
def test_admin_bundle_loads(self, browser_page):
|
||||
assert browser_page.evaluate("typeof window.loadWeekOrders") == "function"
|
||||
page = browser.new_page()
|
||||
page.route(
|
||||
"**/api/form-status/**",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"status":"open"}',
|
||||
),
|
||||
)
|
||||
page.route(
|
||||
"**/api/roster**",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"employees": [
|
||||
{"name": "Ada Lovelace", "email": "ada@example.com"}
|
||||
]
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
yield page
|
||||
browser.close()
|
||||
|
||||
def test_qty_plus_updates_total_without_list_teardown(self, browser_page):
|
||||
page = browser_page
|
||||
|
|
@ -448,84 +290,18 @@ class TestGenerateFormPlaywright:
|
|||
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):
|
||||
def test_qty_typein_clamps_negative(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()
|
||||
# Reset first card via type-in
|
||||
qty = page.locator('[data-meal-index="0"] .qty')
|
||||
qty.fill("-3")
|
||||
qty.dispatch_event("change")
|
||||
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,
|
||||
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'"
|
||||
)
|
||||
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")
|
||||
assert qty.input_value() == "0"
|
||||
# Total may still include other meals from prior test if class-scoped page
|
||||
# is shared — set all known cards to 0 then set one meal to 2 via type-in.
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
|
||||
|
|
@ -537,55 +313,17 @@ class TestGenerateFormPlaywright:
|
|||
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()
|
||||
qty.dispatch_event("change")
|
||||
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"]
|
||||
|
||||
@pytest.fixture
|
||||
def mobile_admin_page(self, tmp_path, browser_page):
|
||||
@pytest.fixture(scope="class")
|
||||
def mobile_admin_page(self, tmp_path_factory, browser_page):
|
||||
html = _render(google=False, api_key="")
|
||||
out = tmp_path / "admin-order-form.html"
|
||||
out = tmp_path_factory.mktemp("admin-form") / "order-form.html"
|
||||
out.write_text(html)
|
||||
hostile_name = """O'Reilly "<b>Ops</b>\""""
|
||||
state = {
|
||||
|
|
@ -663,9 +401,7 @@ class TestGenerateFormPlaywright:
|
|||
body=json.dumps(admin_payload()),
|
||||
)
|
||||
|
||||
browser = browser_page.context.browser
|
||||
assert browser is not None
|
||||
context = browser.new_context(
|
||||
context = browser_page.context.browser.new_context(
|
||||
viewport={"width": 375, "height": 800},
|
||||
has_touch=True,
|
||||
is_mobile=True,
|
||||
|
|
@ -777,165 +513,15 @@ class TestGenerateFormPlaywright:
|
|||
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
|
||||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('admin-status-alert').textContent"
|
||||
"() => document.getElementById('admin-alert').textContent"
|
||||
".includes('hostile update failed')"
|
||||
)
|
||||
state["fail_put"] = False
|
||||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]')
|
||||
assert page.locator("#admin-status-alert").inner_text() == ""
|
||||
assert page.locator("#admin-alert").inner_text() == ""
|
||||
|
||||
page.locator('.admin-order-card [data-admin-action="delete"]').click()
|
||||
page.wait_for_selector("#admin-empty:not(.is-hidden)")
|
||||
assert hostile_name in state["dialogs"][-1]
|
||||
assert state["orders"] == []
|
||||
|
||||
|
||||
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