mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
9 commits
ce67b557da
...
2df66fd36f
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df66fd36f | |||
| fca2ec6044 | |||
| 84cb6251fe | |||
| 58be9ea2f6 | |||
| ae03a227d5 | |||
|
|
216618a862 | ||
|
|
3fe6e74557 | ||
| 043eea1443 | |||
| 2a985c1940 |
8 changed files with 226 additions and 309 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -16,11 +16,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.x
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,13 @@ def latest_menu() -> dict:
|
|||
|
||||
|
||||
def _json_for_script(value) -> str:
|
||||
"""Serialize for embedding in a <script> tag; escape </ to avoid early close."""
|
||||
return json.dumps(value, separators=(",", ":")).replace("</", "<\\/")
|
||||
"""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")
|
||||
)
|
||||
|
||||
|
||||
def generate_form(
|
||||
|
|
@ -58,8 +63,6 @@ def generate_form(
|
|||
use_google_auth = bool(google_client_id)
|
||||
auth_mode = "google" if use_google_auth else "local"
|
||||
|
||||
# apiKey is intentionally embedded in the generated HTML and must be treated
|
||||
# as a shared client token, not a secret or user-authentication credential.
|
||||
form_config = {
|
||||
"authMode": auth_mode,
|
||||
"meals": menu["meals"],
|
||||
|
|
@ -114,9 +117,7 @@ def main():
|
|||
"--api-url", default="", help="API Gateway base URL (cloud mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
default="",
|
||||
help="Shared client token embedded in the generated form (cloud mode)",
|
||||
"--api-key", default="", help="API key for order submission (cloud mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bulk-discount",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* ADMIN_JS_MARKER */
|
||||
(() => {
|
||||
const ADMIN_URL = window.ADMIN_URL;
|
||||
const ADMIN_PDF_URL = window.ADMIN_PDF_URL;
|
||||
const MEALS = window.MEALS;
|
||||
|
|
@ -27,7 +28,10 @@ function loadWeekOrders(week) {
|
|||
document.getElementById('admin-stats').textContent = '';
|
||||
|
||||
fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => renderAdminTable(data))
|
||||
.catch(() => {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
|
||||
|
|
@ -77,8 +81,9 @@ function buildAddItemSelect(availableMeals) {
|
|||
|
||||
availableMeals.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
const price = m.price || 0;
|
||||
opt.value = m.name;
|
||||
opt.textContent = m.name + ' ($' + m.price.toFixed(2) + ')';
|
||||
opt.textContent = m.name + ' ($' + price.toFixed(2) + ')';
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
|
|
@ -111,10 +116,10 @@ function buildEditActions(email) {
|
|||
return wrap;
|
||||
}
|
||||
|
||||
function buildViewActions(idx, email, name) {
|
||||
function buildViewActions(email, name) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'admin-actions';
|
||||
const editBtn = buildActionButton('Edit', 'start-edit', { index: String(idx), email });
|
||||
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';
|
||||
|
|
@ -123,21 +128,7 @@ function buildViewActions(idx, email, name) {
|
|||
return wrap;
|
||||
}
|
||||
|
||||
function validateAdminData(data) {
|
||||
if (!data || !Array.isArray(data.orders)) {
|
||||
throw new Error('Invalid orders response');
|
||||
}
|
||||
if (
|
||||
data.orders.length > 0
|
||||
&& (!Number.isFinite(data.total_employees) || !Number.isFinite(data.grand_total))
|
||||
) {
|
||||
throw new Error('Invalid order aggregates');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderAdminTable(data) {
|
||||
validateAdminData(data);
|
||||
lastAdminData = data;
|
||||
const hasOrders = !!(data.orders && data.orders.length > 0);
|
||||
const dlBtn = document.getElementById('admin-download-btn');
|
||||
|
|
@ -200,12 +191,7 @@ function renderAdminTable(data) {
|
|||
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
|
||||
const newTotal = editNames.reduce((sum, name) => {
|
||||
const price = menuPrices[name] || 0;
|
||||
const employeePrice = window.FormUtils.employeePrice(
|
||||
price,
|
||||
window.CONFIG.bulkDiscount,
|
||||
window.CONFIG.companySubsidy
|
||||
);
|
||||
return sum + employeePrice * (editQuantities[name] || 0);
|
||||
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
|
||||
}, 0);
|
||||
totalHtml = '$' + newTotal.toFixed(2);
|
||||
tr.appendChild(empTd);
|
||||
|
|
@ -244,7 +230,7 @@ function renderAdminTable(data) {
|
|||
if (isEditing) {
|
||||
actionsTd.appendChild(buildEditActions(order.employee_email));
|
||||
} else {
|
||||
actionsTd.appendChild(buildViewActions(idx, order.employee_email, order.employee_name));
|
||||
actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name));
|
||||
}
|
||||
|
||||
tr.appendChild(totalTd);
|
||||
|
|
@ -281,9 +267,9 @@ function downloadOrderList() {
|
|||
}));
|
||||
const names = Object.keys(totals).sort((a, b) => a.localeCompare(b));
|
||||
const esc = v => {
|
||||
const value = String(v);
|
||||
const s = /^[=+\-@]/.test(value) ? "'" + value : value;
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
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');
|
||||
|
|
@ -323,28 +309,22 @@ function downloadSummaryPdf() {
|
|||
.finally(() => { if (btn) btn.disabled = false; });
|
||||
}
|
||||
|
||||
function adminStartEdit(idx, email) {
|
||||
function adminStartEdit(email) {
|
||||
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Request failed with status ' + r.status);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
validateAdminData(data);
|
||||
lastAdminData = data;
|
||||
const order = data.orders.find(o => o.employee_email === email);
|
||||
if (!order || !Array.isArray(order.items)) {
|
||||
throw new Error('Order not found');
|
||||
}
|
||||
if (!order) return;
|
||||
editingOrder = email;
|
||||
editQuantities = {};
|
||||
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
|
||||
renderAdminTable(data);
|
||||
})
|
||||
.catch(e => {
|
||||
if (lastAdminData) renderAdminTable(lastAdminData);
|
||||
alert('Failed to load order: ' + e.message);
|
||||
});
|
||||
.catch(() => alert('Failed to load this order for editing.'));
|
||||
}
|
||||
|
||||
function adminCancelEdit() {
|
||||
|
|
@ -402,7 +382,7 @@ function handleAdminPanelClick(e) {
|
|||
if (btn) {
|
||||
const action = btn.dataset.adminAction;
|
||||
if (action === 'start-edit') {
|
||||
adminStartEdit(parseInt(btn.dataset.index, 10), btn.dataset.email);
|
||||
adminStartEdit(btn.dataset.email);
|
||||
} else if (action === 'delete') {
|
||||
adminDeleteOrder(btn.dataset.email, btn.dataset.name || '');
|
||||
} else if (action === 'save-edit') {
|
||||
|
|
@ -442,3 +422,4 @@ function adminInit() {
|
|||
}
|
||||
|
||||
adminInit();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@
|
|||
--radius-card: 12px;
|
||||
--radius-control: 8px;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--user-avatar-size: 40px;
|
||||
--user-bar-gap: 12px;
|
||||
--user-bar-indent: calc(var(--user-avatar-size) + var(--user-bar-gap));
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
|
@ -78,11 +75,11 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-bar { display: flex; align-items: center; gap: var(--user-bar-gap); }
|
||||
.user-bar { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.user-avatar {
|
||||
width: var(--user-avatar-size);
|
||||
height: var(--user-avatar-size);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: none;
|
||||
|
|
@ -150,7 +147,6 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -346,15 +342,16 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (width < 480px) {
|
||||
@media (max-width: 479px) {
|
||||
.user-bar { flex-wrap: wrap; }
|
||||
.user-meta { flex-basis: calc(100% - var(--user-bar-indent)); }
|
||||
.user-bar #admin-btn { margin-left: var(--user-bar-indent); }
|
||||
.user-avatar.is-hidden + .user-meta { flex-basis: 100%; }
|
||||
.user-avatar.is-hidden ~ #admin-btn { margin-left: 0; }
|
||||
.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: 80px minmax(0, 1fr);
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.qty-control {
|
||||
|
|
@ -482,7 +479,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; margin-top: 0; }
|
||||
.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-toolbar { width: 100%; }
|
||||
|
|
|
|||
|
|
@ -14,22 +14,22 @@
|
|||
</head>
|
||||
<body>
|
||||
<!-- FORM_HTML_MARKER -->
|
||||
<div class="closed-overlay" id="closed-overlay" role="dialog" aria-modal="true" aria-labelledby="closed-title" aria-describedby="closed-description" aria-hidden="true">
|
||||
<div class="closed-card" tabindex="-1">
|
||||
<div class="closed-overlay" id="closed-overlay">
|
||||
<div class="closed-card">
|
||||
<div class="logo">🍽</div>
|
||||
<h1 id="closed-title">Orders Are Closed</h1>
|
||||
<p id="closed-description">Orders open again Monday at 8:00 AM ET</p>
|
||||
<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" role="dialog" aria-modal="true" aria-labelledby="auth-title" aria-describedby="auth-description" aria-hidden="false">
|
||||
<div class="auth-card" tabindex="-1">
|
||||
<div class="auth-overlay" id="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<div class="logo">🍽</div>
|
||||
<h1 id="auth-title">Sea Haven Meal Order</h1>
|
||||
<p id="auth-description">Sign in with your company Google account to place your order.</p>
|
||||
<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>
|
||||
|
|
@ -144,14 +144,10 @@
|
|||
window.CONFIG = {{ config_json|safe }};
|
||||
</script>
|
||||
<script>
|
||||
{
|
||||
{{ form_js|safe }}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
{
|
||||
{{ admin_js|safe }}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ const STATUS_URL = CONFIG.statusUrl;
|
|||
const ROSTER_URL = CONFIG.rosterUrl;
|
||||
const ADMIN_URL = CONFIG.adminUrl;
|
||||
const ADMIN_PDF_URL = CONFIG.adminPdfUrl;
|
||||
// Embedded in the generated form by design; this is a shared client token,
|
||||
// not a secret or a user-authentication credential.
|
||||
const API_KEY = CONFIG.apiKey;
|
||||
const WEEK = CONFIG.week;
|
||||
const BULK_DISCOUNT = CONFIG.bulkDiscount;
|
||||
|
|
@ -28,75 +26,24 @@ let adminWeeks = [];
|
|||
let googleCredential = null;
|
||||
let googleUser = null;
|
||||
let reopenAtMs = null;
|
||||
let countdownTimer = null;
|
||||
let mealsListDelegationBound = false;
|
||||
let countdownInterval = null;
|
||||
let authOverlayReturnFocus = null;
|
||||
let closedOverlayReturnFocus = null;
|
||||
|
||||
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,
|
||||
bulkDiscount = BULK_DISCOUNT,
|
||||
companySubsidy = COMPANY_SUBSIDY
|
||||
) {
|
||||
function employeePrice(retail) {
|
||||
if (!retail) return 0;
|
||||
const bulkPrice = Math.round(retail * (1 - bulkDiscount / 100) * 100 + 1e-8) / 100;
|
||||
const empPrice = Math.round(bulkPrice * (1 - companySubsidy / 100) * 100 + 1e-8) / 100;
|
||||
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
|
||||
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
|
||||
return empPrice;
|
||||
}
|
||||
|
||||
window.FormUtils = { escapeHtml, employeePrice };
|
||||
window.FormUtils = { employeePrice };
|
||||
|
||||
function syncGoogleCredential() {
|
||||
window.googleCredential = googleCredential;
|
||||
}
|
||||
|
||||
function restoreOverlayFocus(previous, fallbackSelector) {
|
||||
if (
|
||||
previous
|
||||
&& previous !== document.body
|
||||
&& previous.isConnected
|
||||
&& !previous.closest('.is-hidden')
|
||||
) {
|
||||
previous.focus();
|
||||
return;
|
||||
}
|
||||
const fallback = document.querySelector(fallbackSelector);
|
||||
if (fallback) fallback.focus();
|
||||
}
|
||||
|
||||
function showAuthOverlay() {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
if (!overlay) return;
|
||||
if (overlay.classList.contains('is-hidden')) {
|
||||
authOverlayReturnFocus = document.activeElement;
|
||||
}
|
||||
overlay.classList.remove('is-hidden');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
overlay.querySelector('.auth-card')?.focus();
|
||||
}
|
||||
|
||||
function hideAuthOverlay() {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
if (!overlay) return;
|
||||
const previous = authOverlayReturnFocus;
|
||||
authOverlayReturnFocus = null;
|
||||
restoreOverlayFocus(previous, '#search');
|
||||
overlay.classList.add('is-hidden');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function buildItems() {
|
||||
return Object.entries(quantities).map(([i, qty]) => ({
|
||||
name: MEALS[i].name,
|
||||
|
|
@ -133,10 +80,10 @@ function handleCredentialResponse(response) {
|
|||
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');
|
||||
hideAuthOverlay();
|
||||
document.getElementById('user-name').textContent = payload.name;
|
||||
document.getElementById('user-email').textContent = payload.email;
|
||||
if (payload.picture) {
|
||||
|
|
@ -147,7 +94,9 @@ function handleCredentialResponse(response) {
|
|||
|
||||
updateTotal();
|
||||
checkDuplicateOrder();
|
||||
checkAdmin();
|
||||
checkAdmin().then(() => {
|
||||
if (formClosed && !isAdmin) showClosedOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
|
|
@ -162,10 +111,10 @@ function signOut() {
|
|||
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');
|
||||
showAuthOverlay();
|
||||
if (formClosed) showClosedOverlay();
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +250,7 @@ function updateMealCardQty(index) {
|
|||
const qty = quantities[index] || 0;
|
||||
card.classList.toggle('selected', qty > 0);
|
||||
const qtyInput = card.querySelector('.qty');
|
||||
if (qtyInput) qtyInput.value = qty;
|
||||
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
|
||||
}
|
||||
|
||||
function setQty(index, value) {
|
||||
|
|
@ -330,14 +279,6 @@ 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;
|
||||
|
|
@ -346,6 +287,13 @@ function bindMealsListDelegation() {
|
|||
const index = parseInt(card.dataset.mealIndex, 10);
|
||||
setQty(index, input.value);
|
||||
});
|
||||
list.addEventListener('focusout', (e) => {
|
||||
const input = e.target.closest('.qty');
|
||||
if (!input) return;
|
||||
const card = input.closest('[data-meal-index]');
|
||||
if (!card) return;
|
||||
updateMealCardQty(parseInt(card.dataset.mealIndex, 10));
|
||||
});
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
|
|
@ -416,44 +364,38 @@ async function submitOrder() {
|
|||
}
|
||||
|
||||
async function loadRoster() {
|
||||
let employees = ROSTER;
|
||||
try {
|
||||
const res = await fetch(ROSTER_URL);
|
||||
const data = await res.json();
|
||||
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);
|
||||
});
|
||||
}
|
||||
const candidate = data.employees;
|
||||
if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) {
|
||||
employees = candidate;
|
||||
} else {
|
||||
employees = ROSTER;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
employees.forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
});
|
||||
}
|
||||
|
||||
function showClosedOverlay() {
|
||||
const overlay = document.getElementById('closed-overlay');
|
||||
if (!overlay.classList.contains('is-visible')) {
|
||||
closedOverlayReturnFocus = document.activeElement;
|
||||
}
|
||||
overlay.classList.add('is-visible');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
overlay.querySelector('.closed-card')?.focus();
|
||||
document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = true; });
|
||||
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);
|
||||
|
|
@ -461,15 +403,8 @@ function showClosedOverlay() {
|
|||
|
||||
function adminBypassClosed() {
|
||||
const overlay = document.getElementById('closed-overlay');
|
||||
if (countdownInterval !== null) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
const previous = closedOverlayReturnFocus;
|
||||
closedOverlayReturnFocus = null;
|
||||
restoreOverlayFocus(previous, '#search');
|
||||
overlay.classList.remove('is-visible');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
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;
|
||||
|
|
@ -490,10 +425,10 @@ function adminBypassClosed() {
|
|||
}
|
||||
|
||||
function checkAdmin() {
|
||||
if (!ADMIN_URL) return;
|
||||
if (!ADMIN_URL) return Promise.resolve();
|
||||
const headers = {};
|
||||
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential;
|
||||
fetch(ADMIN_URL, { headers })
|
||||
return fetch(ADMIN_URL, { headers })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data && data.weeks) {
|
||||
|
|
@ -532,8 +467,8 @@ function showAdmin() {
|
|||
opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (adminWeeks.length > 0 && typeof window.loadWeekOrders === 'function') {
|
||||
window.loadWeekOrders(adminWeeks[0].week);
|
||||
if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') {
|
||||
loadWeekOrders(adminWeeks[0].week);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -575,8 +510,8 @@ function getEasternOffsetMs(date) {
|
|||
function startCountdown(data) {
|
||||
if (data && data.reopen_at) reopenAtMs = data.reopen_at * 1000;
|
||||
updateCountdown();
|
||||
if (countdownInterval !== null) clearInterval(countdownInterval);
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
if (countdownTimer !== null) clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
|
|
@ -617,7 +552,7 @@ async function checkFormStatus() {
|
|||
formStatusData = data;
|
||||
if (isAdmin) {
|
||||
adminBypassClosed();
|
||||
} else {
|
||||
} else if (CONFIG.authMode !== 'google' || googleCredential) {
|
||||
showClosedOverlay();
|
||||
}
|
||||
}
|
||||
|
|
@ -627,7 +562,6 @@ async function checkFormStatus() {
|
|||
function init() {
|
||||
checkFormStatus();
|
||||
if (CONFIG.authMode === 'google') {
|
||||
showAuthOverlay();
|
||||
waitForGoogleAuth();
|
||||
} else {
|
||||
loadRoster();
|
||||
|
|
@ -671,6 +605,5 @@ window.showAdmin = showAdmin;
|
|||
window.hideAdmin = hideAdmin;
|
||||
window.submitOrder = submitOrder;
|
||||
window.changeQty = changeQty;
|
||||
window.handleCredentialResponse = handleCredentialResponse;
|
||||
|
||||
init();
|
||||
|
|
|
|||
12
tests/fixtures/menu.json
vendored
12
tests/fixtures/menu.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"scraped_at": "2026-07-27T12:00:00+00:00",
|
||||
"meal_count": 4,
|
||||
"meal_count": 5,
|
||||
"meals": [
|
||||
{
|
||||
"name": "Chicken Bowl",
|
||||
|
|
@ -45,6 +45,16 @@
|
|||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -70,8 +70,8 @@ def _render(*, google: bool = False, api_key: str = "test-key") -> str:
|
|||
def _google_credential() -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"name": "Adam Moussa",
|
||||
"email": "adam.moussa@seahavenind.com",
|
||||
"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'/>"
|
||||
|
|
@ -146,22 +146,6 @@ class TestGenerateFormStructural:
|
|||
admin_pos = html.index("ADMIN_JS_MARKER")
|
||||
assert form_pos < admin_pos
|
||||
|
||||
def test_inline_scripts_have_independent_block_scopes(self):
|
||||
html = _render()
|
||||
assert "<script>\n{\n/* FORM_JS_MARKER */" in html
|
||||
assert "<script>\n{\n/* ADMIN_JS_MARKER */" in html
|
||||
|
||||
def test_overlays_have_dialog_semantics(self):
|
||||
html = _render(google=True)
|
||||
assert (
|
||||
'id="auth-overlay" role="dialog" aria-modal="true" '
|
||||
'aria-labelledby="auth-title"'
|
||||
) in html
|
||||
assert (
|
||||
'id="closed-overlay" role="dialog" aria-modal="true" '
|
||||
'aria-labelledby="closed-title"'
|
||||
) in html
|
||||
|
||||
def test_single_submit_order_definition(self):
|
||||
html = _render(google=True)
|
||||
# One async function submitOrder in the inlined form.js
|
||||
|
|
@ -170,7 +154,6 @@ 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)
|
||||
|
||||
|
|
@ -182,7 +165,6 @@ 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(
|
||||
|
|
@ -195,6 +177,7 @@ 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()
|
||||
|
|
@ -220,14 +203,10 @@ class TestGenerateFormStructural:
|
|||
|
||||
|
||||
class TestGenerateFormPlaywright:
|
||||
@pytest.fixture(scope="class")
|
||||
@pytest.fixture
|
||||
def browser_page(self, 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
|
||||
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
|
||||
|
|
@ -243,33 +222,41 @@ class TestGenerateFormPlaywright:
|
|||
"Chromium is required for form Playwright tests. "
|
||||
"Run: playwright install --with-deps chromium"
|
||||
) from exc
|
||||
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"}
|
||||
]
|
||||
}
|
||||
try:
|
||||
page = browser.new_page()
|
||||
page.route(
|
||||
"**/api/form-status/**",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"status":"open"}',
|
||||
),
|
||||
),
|
||||
)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
yield page
|
||||
browser.close()
|
||||
)
|
||||
page.route(
|
||||
"**/api/roster**",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"employees": [
|
||||
{
|
||||
"name": "Ada Lovelace",
|
||||
"email": "ada@example.com",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
yield page
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
def test_admin_bundle_loads(self, browser_page):
|
||||
assert browser_page.evaluate("typeof window.loadWeekOrders") == "function"
|
||||
|
||||
def test_qty_plus_updates_total_without_list_teardown(self, browser_page):
|
||||
page = browser_page
|
||||
|
|
@ -310,32 +297,19 @@ class TestGenerateFormPlaywright:
|
|||
# Reset first card via type-in
|
||||
qty = page.locator('[data-meal-index="0"] .qty')
|
||||
qty.fill("-3")
|
||||
qty.dispatch_event("change")
|
||||
qty.blur()
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'"
|
||||
)
|
||||
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) => {
|
||||
input.value = '0';
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}"""
|
||||
)
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === '0 meals'"
|
||||
)
|
||||
qty.fill("2")
|
||||
qty.dispatch_event("change")
|
||||
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])
|
||||
@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})
|
||||
|
|
@ -369,52 +343,54 @@ class TestGenerateFormGooglePlaywright:
|
|||
"Chromium is required for form Playwright tests. "
|
||||
"Run: playwright install --with-deps chromium"
|
||||
) from exc
|
||||
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(),
|
||||
)
|
||||
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="{}",
|
||||
)
|
||||
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
|
||||
browser.close()
|
||||
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(
|
||||
|
|
@ -430,7 +406,32 @@ class TestGenerateFormGooglePlaywright:
|
|||
"#admin-btn",
|
||||
"#sign-out-btn",
|
||||
):
|
||||
assert page.locator(selector).is_visible()
|
||||
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()
|
||||
|
||||
|
|
@ -459,9 +460,7 @@ class TestGenerateFormGooglePlaywright:
|
|||
)
|
||||
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
||||
|
||||
def test_user_bar_wraps_below_shared_mobile_breakpoint(
|
||||
self, signed_in_admin_page
|
||||
):
|
||||
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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue