+
+
>} */
const listeners = new Set();
+/** @type {Set<(busy: boolean) => void>} */
+const busyListeners = new Set();
+
+/** @type {Set<{ el: HTMLElement, close: () => void }>} */
+const openPickerClosers = new Set();
+let documentClickBound = false;
function interpolate(template, vars = {}) {
return String(template).replace(/\{(\w+)\}/g, (_, key) => {
@@ -15,6 +23,30 @@ function interpolate(template, vars = {}) {
});
}
+function ensureDocumentClickHandler() {
+ if (documentClickBound) return;
+ documentClickBound = true;
+ document.addEventListener("click", (e) => {
+ openPickerClosers.forEach((picker) => {
+ if (!picker.el.contains(e.target)) {
+ picker.close();
+ }
+ });
+ });
+}
+
+function setLangBusy(busy) {
+ langBusy = !!busy;
+ document.documentElement.classList.toggle("is-lang-loading", langBusy);
+ busyListeners.forEach((cb) => {
+ try {
+ cb(langBusy);
+ } catch (err) {
+ console.warn("[i18n] busy listener failed", err);
+ }
+ });
+}
+
export function getAvailableLanguages() {
const fromConfig = window.APP_CONFIG?.languages;
if (Array.isArray(fromConfig) && fromConfig.length) {
@@ -31,6 +63,10 @@ export function getLang() {
return currentLang;
}
+export function isLangBusy() {
+ return langBusy;
+}
+
export function t(key, vars = {}) {
const catalog = catalogs[currentLang] || catalogs.pl;
const fallback = catalogs.pl;
@@ -72,7 +108,12 @@ export function onLangChange(callback) {
return () => listeners.delete(callback);
}
-export function setLang(lang, { persist = true, notify = true } = {}) {
+export function onLangBusyChange(callback) {
+ busyListeners.add(callback);
+ return () => busyListeners.delete(callback);
+}
+
+export async function setLang(lang, { persist = true, notify = true } = {}) {
const next = catalogs[lang] ? lang : "pl";
const changed = next !== currentLang;
currentLang = next;
@@ -87,14 +128,38 @@ export function setLang(lang, { persist = true, notify = true } = {}) {
applyTranslations();
- if (notify && changed) {
+ if (!(notify && changed)) {
+ return currentLang;
+ }
+
+ if (langBusy) {
+ return currentLang;
+ }
+
+ const startedAt = Date.now();
+ setLangBusy(true);
+
+ try {
+ const tasks = [];
listeners.forEach((cb) => {
try {
- cb(currentLang);
+ const result = cb(currentLang);
+ if (result != null && typeof result.then === "function") {
+ tasks.push(result);
+ }
} catch (err) {
console.warn("[i18n] listener failed", err);
}
});
+ if (tasks.length) {
+ await Promise.allSettled(tasks);
+ }
+ } finally {
+ const wait = Math.max(0, LANG_BUSY_MIN_MS - (Date.now() - startedAt));
+ if (wait > 0) {
+ await new Promise((resolve) => setTimeout(resolve, wait));
+ }
+ setLangBusy(false);
}
return currentLang;
@@ -116,7 +181,13 @@ function flagImgHtml(code) {
return `
`;
}
+function spinnerHtml() {
+ return ``;
+}
+
export function createLanguagePicker({ idPrefix = "lang" } = {}) {
+ ensureDocumentClickHandler();
+
const wrap = document.createElement("div");
wrap.className = "lang-picker";
wrap.dataset.langPicker = "1";
@@ -133,13 +204,41 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
menu.id = `${idPrefix}Menu`;
menu.setAttribute("role", "listbox");
- function refresh() {
+ const pickerApi = {
+ el: wrap,
+ close: () => {
+ menu.classList.add("hidden");
+ btn.setAttribute("aria-expanded", "false");
+ wrap.classList.remove("is-open");
+ openPickerClosers.delete(pickerApi);
+ },
+ };
+
+ function paintButton() {
const langs = getAvailableLanguages();
const current = langs.find((l) => l.code === getLang()) || langs[0];
const code = current?.code || "pl";
+
+ if (langBusy) {
+ btn.innerHTML = spinnerHtml();
+ btn.disabled = true;
+ btn.setAttribute("aria-busy", "true");
+ btn.setAttribute("aria-label", t("lang.loading"));
+ wrap.classList.add("is-loading");
+ return;
+ }
+
+ btn.disabled = false;
+ btn.setAttribute("aria-busy", "false");
btn.innerHTML = flagImgHtml(code);
btn.setAttribute("aria-label", `${t("lang.aria")}: ${current?.label || code}`);
+ wrap.classList.remove("is-loading");
+ }
+ function refresh() {
+ paintButton();
+
+ const langs = getAvailableLanguages();
menu.innerHTML = "";
langs.forEach((lang) => {
const option = document.createElement("button");
@@ -147,12 +246,14 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
option.className = "lang-picker-option" + (lang.code === getLang() ? " is-active" : "");
option.setAttribute("role", "option");
option.setAttribute("aria-selected", lang.code === getLang() ? "true" : "false");
+ option.disabled = langBusy;
option.innerHTML = `${flagImgHtml(lang.code)}${lang.label}`;
option.addEventListener("click", (e) => {
e.stopPropagation();
- close();
+ if (langBusy) return;
+ pickerApi.close();
if (lang.code !== getLang()) {
- setLang(lang.code);
+ void setLang(lang.code);
}
});
menu.appendChild(option);
@@ -160,25 +261,21 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
}
function open() {
+ if (langBusy) return;
+ openPickerClosers.forEach((p) => {
+ if (p !== pickerApi) p.close();
+ });
menu.classList.remove("hidden");
btn.setAttribute("aria-expanded", "true");
wrap.classList.add("is-open");
- }
-
- function close() {
- menu.classList.add("hidden");
- btn.setAttribute("aria-expanded", "false");
- wrap.classList.remove("is-open");
+ openPickerClosers.add(pickerApi);
}
btn.addEventListener("click", (e) => {
e.stopPropagation();
+ if (langBusy) return;
if (menu.classList.contains("hidden")) open();
- else close();
- });
-
- document.addEventListener("click", (e) => {
- if (!wrap.contains(e.target)) close();
+ else pickerApi.close();
});
wrap.appendChild(btn);
@@ -186,6 +283,10 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
refresh();
onLangChange(() => refresh());
+ onLangBusyChange(() => {
+ if (langBusy) pickerApi.close();
+ paintButton();
+ });
- return { el: wrap, refresh, close };
+ return { el: wrap, refresh, close: pickerApi.close };
}
diff --git a/public/assets/js/modules/menu.js b/public/assets/js/modules/menu.js
index f6e3e5e..52f77b7 100644
--- a/public/assets/js/modules/menu.js
+++ b/public/assets/js/modules/menu.js
@@ -7,7 +7,19 @@ let itemModalIndex = -1;
let itemModalTouchStart = null;
let itemModalDragging = false;
let itemModalAnimating = false;
-let menuLangChangeBound = false;
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function escapeAttr(value) {
+ return escapeHtml(value).replace(/`/g, "`");
+}
function resetItemModalPane() {
const pane = document.getElementById("itemModalPane");
@@ -77,10 +89,10 @@ function renderMenuListImage(url) {
const hasUrl = isValidMenuImageUrl(url);
const imgClass = hasUrl ? "rmc-image" : "rmc-image hidden";
const placeholderClass = hasUrl ? "menu-image-placeholder hidden" : "menu-image-placeholder";
- const srcAttr = hasUrl ? ` src="${url}"` : "";
+ const srcAttr = hasUrl ? ` src="${escapeAttr(url)}"` : "";
const onerror = hasUrl ? ' onerror="handleMenuImageError(this)"' : "";
- return `
`;
+ return `
`;
}
function findMenuItem(categoryId, position) {
@@ -250,13 +262,13 @@ function renderCategoryNav(categories) {
if (!ul) return;
const list = Array.isArray(categories) ? categories : [];
- let html = `${t("menu.all")} `;
+ let html = `${escapeHtml(t("menu.all"))} `;
list.forEach((cat) => {
const id = String(cat?.id ?? "").trim();
const name = String(cat?.name ?? "").trim();
if (!id || !name) return;
- html += `${name} `;
+ html += `${escapeHtml(name)} `;
});
ul.innerHTML = html;
@@ -264,11 +276,19 @@ function renderCategoryNav(categories) {
function showMenuLoadError(container) {
if (!container) return;
- container.innerHTML = `
-
-
-
-
-
-
-
-
-
-
+
+
- Aktualny status
-
+
+
+
+
+
+
+
+
+
+
+
+ Oczekiwanie...
+
+
+
+
+ Aktualny status
+
+ Oczekiwanie...
+ ⏳
+
+
+
+
+
+ Sprawdzamy co pysznego się przygotowuje...
+
+ Twoje zamówione dania
+
+
+
+ 📖
+ Jeśli właśnie złożyłeś zamówienie, daj nam chwilkę na jego + przetworzenie.
+ +Twoje poprzednie zamówienia
+To są pozycje z innych wizyt, które były widoczne na tym telefonie po zeskanowaniu + kodów QR. Jeśli kiedyś byłeś w restauracji bez skanowania kodu, tych pozycji tu nie będzie 🙂
+ +
+ Usuń historię
+
+
+
-
+ Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.
+
+
+
+
+
+
+
+
+
+
+
-
+
+
⏳
-
-
+
-
- Sprawdzamy co pysznego się przygotowuje...
-
-
-
- Twoje zamówione dania
-
-
-
- 📖
- Jeśli właśnie złożyłeś zamówienie, daj nam chwilkę na jego - przetworzenie.
- -Twoje poprzednie zamówienia
-To są pozycje z innych wizyt, które były widoczne na tym telefonie po zeskanowaniu - kodów QR. Jeśli kiedyś byłeś w restauracji bez skanowania kodu, tych pozycji tu nie będzie 🙂
- -
- Usuń historię
-
-
-
-
-
- Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.
-
-
-
-
-
-
-
-
-
-
-
+
-
-
- 🛎️
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 6bef6fb..9ac6e6f 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -9,6 +9,8 @@
--success: #4ade80;
--accent: #f59e0b;
--radius: 20px;
+ --bottom-nav-height: 64px;
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
}
* {
@@ -16,13 +18,19 @@
-webkit-tap-highlight-color: transparent;
}
+html {
+ height: 100%;
+}
+
body {
margin: 0;
+ height: 100%;
+ height: 100dvh;
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.6;
- overflow-x: hidden;
+ overflow: hidden;
}
/* --- LOADER SCREEN --- */
@@ -97,20 +105,31 @@ body {
display: inline-flex;
align-items: center;
justify-content: center;
+ width: 44px;
+ height: 34px;
background: var(--surface, #1e293b);
border: 1px solid var(--surface-light, #334155);
color: var(--text-main, #f8fafc);
border-radius: 10px;
- padding: 6px 8px;
+ padding: 0;
cursor: pointer;
font-family: inherit;
line-height: 0;
}
-.lang-picker-btn:hover {
+.lang-picker-btn:hover:not(:disabled) {
border-color: var(--primary, #e2b07e);
}
+.lang-picker-btn:disabled {
+ cursor: wait;
+ opacity: 1;
+}
+
+.lang-picker.is-loading .lang-picker-btn {
+ border-color: rgba(226, 176, 126, 0.55);
+}
+
.lang-flag-img {
display: block;
width: 28px;
@@ -120,6 +139,27 @@ body {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
}
+.lang-picker-spinner {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid rgba(226, 176, 126, 0.25);
+ border-top-color: var(--primary, #e2b07e);
+ animation: langSpin 0.65s linear infinite;
+}
+
+@keyframes langSpin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+html.is-lang-loading .app-scroll {
+ pointer-events: none;
+ opacity: 0.72;
+ transition: opacity 0.15s ease;
+}
+
.lang-picker-menu {
position: absolute;
right: 0;
@@ -154,6 +194,11 @@ body {
background: rgba(226, 176, 126, 0.15);
}
+.lang-picker-option:disabled {
+ opacity: 0.5;
+ cursor: wait;
+}
+
.header-top {
display: flex;
align-items: center;
@@ -341,11 +386,40 @@ body {
}
}
-/* --- MAIN LAYOUT --- */
-.container {
+/* --- MAIN LAYOUT (flex column: scroll + bottom bar) --- */
+.app-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ height: 100dvh;
max-width: 500px;
margin: 0 auto;
- padding: 24px 16px 100px;
+ background: var(--bg);
+}
+
+.app-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+}
+
+.container {
+ padding: 24px 16px 28px;
+}
+
+.app-footer {
+ text-align: center;
+ padding: 10px 0 8px;
+ margin-top: 5px;
+}
+
+.app-footer a {
+ font-size: 12px;
+ color: var(--text-muted);
+ text-decoration: none;
}
header {
@@ -1046,25 +1120,25 @@ header {
/* --- BOTTOM NAVIGATION BAR --- */
.bottom-nav {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: 70px;
- background: rgba(28, 28, 31, 0.85);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-top: 1px solid rgba(255, 255, 255, 0.05);
+ flex: 0 0 auto;
+ position: relative;
+ z-index: 20;
display: flex;
justify-content: space-around;
- align-items: center;
- padding: 5px 10px;
- padding-bottom: env(safe-area-inset-bottom, 5px);
- z-index: 100;
- box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3);
+ align-items: stretch;
+ height: calc(var(--bottom-nav-height) + var(--safe-bottom));
+ padding: 0 4px var(--safe-bottom);
+ background: rgba(28, 28, 31, 0.96);
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
+}
+
+.bottom-nav[hidden] {
+ display: none !important;
}
.nav-item {
+ position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -1073,26 +1147,38 @@ header {
text-decoration: none;
font-size: 11px;
font-weight: 600;
- gap: 4px;
- width: 25%;
- height: 100%;
+ gap: 2px;
+ flex: 1 1 0;
+ min-width: 0;
+ height: var(--bottom-nav-height);
+ padding: 0 2px;
cursor: pointer;
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
+ user-select: none;
}
.nav-icon {
+ display: block;
font-size: 22px;
- transition: transform 0.3s;
+ line-height: 1;
filter: grayscale(1) opacity(0.6);
}
+.nav-label {
+ display: block;
+ max-width: 100%;
+ line-height: 1.15;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+}
+
.nav-item.active {
color: var(--primary);
}
.nav-item.active .nav-icon {
- transform: translateY(-2px) scale(1.1);
filter: grayscale(0) opacity(1);
}
@@ -1116,20 +1202,31 @@ header {
opacity: 0.45;
}
-.nav-item.nav-action-pending .nav-label::after {
- content: " · w toku";
- font-size: 9px;
- font-weight: 500;
+.nav-item.nav-action-pending::before {
+ content: "";
+ position: absolute;
+ top: 8px;
+ right: max(8px, calc(50% - 18px));
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 0 2px rgba(28, 28, 31, 0.9);
}
.nav-item.nav-locked {
opacity: 0.48;
- filter: grayscale(0.35);
}
-.nav-item.nav-locked .nav-label::after {
- content: " 🔒";
- font-size: 10px;
+.nav-item.nav-locked::after {
+ content: "🔒";
+ position: absolute;
+ top: 6px;
+ right: max(6px, calc(50% - 20px));
+ font-size: 9px;
+ line-height: 1;
+ pointer-events: none;
+ opacity: 0.9;
}
.menu-only-banner {
@@ -1164,10 +1261,6 @@ header {
cursor: pointer;
}
-.nav-item:active .nav-icon {
- transform: scale(0.9);
-}
-
/* SPA View switching */
.view-section {
display: none;
diff --git a/public/assets/js/locales/de.js b/public/assets/js/locales/de.js
index 5a53ddf..82af3be 100644
--- a/public/assets/js/locales/de.js
+++ b/public/assets/js/locales/de.js
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Gesendet!",
"lang.aria": "Sprache wählen",
+ "lang.loading": "Sprache wird geladen…",
};
diff --git a/public/assets/js/locales/en.js b/public/assets/js/locales/en.js
index a94bce7..4bec363 100644
--- a/public/assets/js/locales/en.js
+++ b/public/assets/js/locales/en.js
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Sent!",
"lang.aria": "Choose language",
+ "lang.loading": "Loading language…",
};
diff --git a/public/assets/js/locales/pl.js b/public/assets/js/locales/pl.js
index 454947c..da04fd2 100644
--- a/public/assets/js/locales/pl.js
+++ b/public/assets/js/locales/pl.js
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Wysłano!",
"lang.aria": "Wybierz język",
+ "lang.loading": "Ładowanie języka…",
};
diff --git a/public/assets/js/modules/access.js b/public/assets/js/modules/access.js
index 4a0797c..8780ba1 100644
--- a/public/assets/js/modules/access.js
+++ b/public/assets/js/modules/access.js
@@ -34,7 +34,7 @@ export function updateNavAccessState() {
export function showBottomNav() {
const bottomNav = document.getElementById("bottomNav");
- if (bottomNav) bottomNav.style.display = "";
+ if (bottomNav) bottomNav.hidden = false;
updateNavAccessState();
}
@@ -93,6 +93,8 @@ export function switchTabInternal(tabName) {
if (greetingBanner) greetingBanner.style.display = "none";
}
window.scrollTo(0, 0);
+ const appScroll = document.getElementById("appScroll");
+ if (appScroll) appScroll.scrollTop = 0;
}
export function switchTab(tabName) {
diff --git a/public/assets/js/modules/bill.js b/public/assets/js/modules/bill.js
index 71f5dc8..f59dd62 100644
--- a/public/assets/js/modules/bill.js
+++ b/public/assets/js/modules/bill.js
@@ -15,16 +15,16 @@ import { showToast } from "./toast.js";
import {
getBillState,
getHashParam,
- getTableParam,
setBillState,
} from "./state.js";
-function sendApiSimulated(actionName, details) {
- console.log(`[SYMULACJA API] Akcja: ${actionName}`, details);
- // Przykładowe wysłanie docelowo:
- // if (window.socket && window.socket.readyState === WebSocket.OPEN) {
- // window.socket.send(JSON.stringify({ action: "sendUpstream", payload: { type: actionName, table: tableParam, ...details } }));
- // }
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
export async function callWaiter(type) {
@@ -48,7 +48,6 @@ export async function callWaiter(type) {
}
trackEvent("waiter_call_requested", { waiterType: "order" });
- sendApiSimulated("CallWaiter_Order", { table: getTableParam() });
showToast(t("waiter.toast_ok"));
}
@@ -142,10 +141,10 @@ function renderBillList(bills) {
const numerFormat = b.numer ? `#${b.numer}` : t("bill.bill_fallback");
div.innerHTML = `
-
- ${numerFormat}
- ${b.opis}
+ ${escapeHtml(numerFormat)}
+ ${escapeHtml(b.opis)}
${b.suma.toFixed(2)} PLN
+ ${Number(b.suma).toFixed(2)} PLN
`;
container.appendChild(div);
});
@@ -172,10 +171,10 @@ export function showBillReview(bill) {
div.innerHTML = `
-
- ${p.nazwa}
- ${p.ilosc} x ${p.cena.toFixed(2)} PLN
+ ${escapeHtml(p.nazwa)}
+ ${escapeHtml(p.ilosc)} x ${Number(p.cena).toFixed(2)} PLN
${p.wartosc.toFixed(2)} PLN
+ ${Number(p.wartosc).toFixed(2)} PLN
`;
content.appendChild(div);
});
@@ -220,12 +219,6 @@ export async function selectDocument(docType) {
}
trackEvent("bill_request_sent", { docType: "paragon" });
closeBillDialog();
- sendApiSimulated("CallWaiter_Bill", {
- table: getTableParam(),
- billId: billState.selectedBillId,
- payment: billState.payment,
- doc: "paragon",
- });
showToast(t("bill.toast_receipt"));
} else {
goToStep("stepNIP");
@@ -343,13 +336,5 @@ export async function confirmInvoice() {
closeBillDialog();
trackEvent("bill_request_sent", { docType: "faktura" });
- sendApiSimulated("CallWaiter_Bill", {
- table: getTableParam(),
- billId: billState.selectedBillId,
- payment: billState.payment,
- doc: "faktura",
- nip: billState.nip,
- company: billState.company,
- });
showToast(t("bill.toast_invoice"));
}
diff --git a/public/assets/js/modules/config.js b/public/assets/js/modules/config.js
index 1245d46..8e261be 100644
--- a/public/assets/js/modules/config.js
+++ b/public/assets/js/modules/config.js
@@ -14,12 +14,4 @@ export const endpoints = {
menu: cfg.endpoints?.menu || "../api/menu.php",
};
-export const availableLanguages = Array.isArray(cfg.languages)
- ? cfg.languages
- : [
- { code: "pl", label: "Polski", flag: "🇵🇱" },
- { code: "en", label: "English", flag: "🇬🇧" },
- { code: "de", label: "Deutsch", flag: "🇩🇪" },
- ];
-
export const loaderMinMs = Number(cfg.loaderMinMs) || 10_000;
diff --git a/public/assets/js/modules/geo.js b/public/assets/js/modules/geo.js
index da06fff..e584340 100644
--- a/public/assets/js/modules/geo.js
+++ b/public/assets/js/modules/geo.js
@@ -1,5 +1,5 @@
import { endpoints, geoBypassHosts } from "./config.js";
-import { t } from "./i18n.js";
+import { onLangChange, t } from "./i18n.js";
import { trackEvent } from "./analytics.js";
import {
runPendingProtectedAction,
@@ -20,95 +20,20 @@ import {
setPendingProtectedAction,
} from "./state.js";
-// USER PROFILE LOGIC
-const userProfileKey = "karczma_user_profile";
-const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months
+/** @typedef {'consent'|'gate'|'blocked'|'checking'|'outside'|'https'|'unsupported'|'failed'} GeoUiMode */
-export function initUserProfile() {
- return; // Funkcja tymczasowo wyłączona
- try {
- const raw = localStorage.getItem(userProfileKey);
- let profile = null;
- if (raw) {
- profile = JSON.parse(raw);
- }
-
- const now = Date.now();
-
- // Check if profile exists and is valid
- if (profile) {
- if (profile.declined) {
- // User declined in the past, don't ask again.
- return;
- }
-
- // If expired, maybe we want to ask again or we could just keep it if they didn't decline.
- // The user said: "trzymamy przez wiele miesięcy (odnawiamy datę przy każdej wizycie)"
- if (now - profile.lastVisit > USER_PROFILE_EXPIRE_MS) {
- // Profile expired. Ask again.
- showNameDialog();
- } else {
- // Profile valid, renew date and show greeting
- profile.lastVisit = now;
- localStorage.setItem(userProfileKey, JSON.stringify(profile));
- showGreeting(profile.name, profile.firstVisit || profile.lastVisit);
- }
- } else {
- // No profile, ask for name
- showNameDialog();
- }
- } catch (err) {
- // If error parsing, ask again
- showNameDialog();
- }
-}
-
-function showNameDialog() {
- const modal = document.getElementById("nameModal");
- if (modal) {
- modal.classList.add("active");
- document.body.style.overflow = "hidden";
- }
-}
-
-function hideNameDialog() {
- const modal = document.getElementById("nameModal");
- if (modal) {
- modal.classList.remove("active");
- document.body.style.overflow = "";
- }
-}
-
-export function saveUserName() {
- const input = document.getElementById("userNameInput").value.trim();
- if (input) {
- const now = Date.now();
- const profile = { name: input, firstVisit: now, lastVisit: now, declined: false };
- localStorage.setItem(userProfileKey, JSON.stringify(profile));
- hideNameDialog();
- showGreeting(profile.name, profile.firstVisit);
- }
-}
-
-export function declineUserName() {
- const profile = { name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true };
- localStorage.setItem(userProfileKey, JSON.stringify(profile));
- hideNameDialog();
-}
-
-function showGreeting(name, firstVisitTime) {
- const banner = document.getElementById("greetingBanner");
- if (banner && name) {
- const isToday = new Date(firstVisitTime).toDateString() === new Date().toDateString();
-
- if (isToday) {
- banner.innerHTML = `Cześć ${name}, życzymy pysznego posiłku!`;
- } else {
- banner.innerHTML = `Witaj ${name}, super że do nas wracasz!`;
- }
- banner.style.display = "block";
- }
-}
+const geoUi = {
+ /** @type {GeoUiMode} */
+ mode: "consent",
+ pendingAction: null,
+ outsideDist: 0,
+ outsideAccuracy: 0,
+ actionBusy: false,
+ /** @type {'locate'|'check'|'retry'|'checking'} */
+ actionLabel: "locate",
+ /** @type {'menu_only'|'back_to_menu'} */
+ secondaryMode: "menu_only",
+};
function GEO_GATE_LABELS() {
return {
@@ -118,8 +43,6 @@ function GEO_GATE_LABELS() {
};
}
-const GEO_DEFAULT_LEAD = () => t("geo.lead");
-
function setGeoLead(html) {
const el = document.getElementById("geoLead");
if (el) el.innerHTML = html;
@@ -144,20 +67,6 @@ function hideGeoInstructions() {
showGeoInstructions("");
}
-function setGeoActionBusy(busy) {
- const btn = document.getElementById("geoActionBtn");
- if (!btn) return;
- btn.disabled = false;
- btn.setAttribute("aria-busy", busy ? "true" : "false");
- if (busy) {
- setGeoActionLabel(t("geo.btn.checking"));
- }
-}
-
-function isGeoPermissionDenied(error) {
- return Number(error?.code) === 1;
-}
-
function setGeoActionLabel(text) {
const btn = document.getElementById("geoActionBtn");
if (!btn) return;
@@ -166,14 +75,145 @@ function setGeoActionLabel(text) {
else btn.textContent = text;
}
-function getGeoPermissionInstructions() {
- return t("geo.hint.permission");
+function applyGeoActionLabel() {
+ const keys = {
+ locate: "geo.btn.locate",
+ check: "geo.btn.check",
+ retry: "geo.btn.retry",
+ checking: "geo.btn.checking",
+ };
+ setGeoActionLabel(t(keys[geoUi.actionLabel] || keys.locate));
}
-let geoMenuButtonMode = "menu_only";
+function setGeoActionBusy(busy) {
+ const btn = document.getElementById("geoActionBtn");
+ if (!btn) return;
+ btn.disabled = false;
+ btn.setAttribute("aria-busy", busy ? "true" : "false");
+ geoUi.actionBusy = !!busy;
+ if (busy) {
+ geoUi.actionLabel = "checking";
+ }
+ applyGeoActionLabel();
+}
+
+function applyGeoSecondaryButton() {
+ const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
+ if (!menuOnlyBtn) return;
+
+ const mainEl = menuOnlyBtn.querySelector(".geo-btn-main");
+ const subEl = menuOnlyBtn.querySelector(".geo-btn-sub");
+ menuOnlyBtn.style.display = "";
+
+ if (geoUi.secondaryMode === "back_to_menu") {
+ if (mainEl) mainEl.textContent = t("geo.btn.back_menu");
+ if (subEl) {
+ subEl.textContent = "";
+ subEl.style.display = "none";
+ }
+ return;
+ }
+
+ if (mainEl) mainEl.textContent = t("geo.btn.menu_main");
+ if (subEl) {
+ subEl.textContent = t("geo.btn.menu_sub");
+ subEl.style.display = "";
+ }
+}
+
+function refreshGeoCopy() {
+ const secondary =
+ getAppAccessLevel() === "menu" && geoUi.secondaryMode === "back_to_menu"
+ ? "back_to_menu"
+ : geoUi.secondaryMode;
+
+ geoUi.secondaryMode = secondary;
+
+ switch (geoUi.mode) {
+ case "gate": {
+ const feature = GEO_GATE_LABELS()[geoUi.pendingAction] || t("geo.feature.other");
+ setGeoLead(t("geo.lead_action", { feature }));
+ break;
+ }
+ case "blocked":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(t("geo.status.blocked"), { error: true });
+ showGeoInstructions(t("geo.hint.permission"));
+ break;
+ case "checking":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(t("geo.status.checking"), { info: true });
+ hideGeoInstructions();
+ break;
+ case "outside":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(
+ t("geo.status.outside", {
+ dist: geoUi.outsideDist,
+ accuracy: geoUi.outsideAccuracy,
+ }),
+ { error: true }
+ );
+ showGeoInstructions(t("geo.hint.outside"));
+ break;
+ case "https":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(t("geo.status.https"), { error: true });
+ showGeoInstructions(t("geo.hint.https"));
+ break;
+ case "unsupported":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(t("geo.status.unsupported"), { error: true });
+ hideGeoInstructions();
+ break;
+ case "failed":
+ setGeoLead(t("geo.lead"));
+ setGeoStatus(t("geo.status.failed"), { error: true });
+ showGeoInstructions(t("geo.hint.retry"));
+ break;
+ case "consent":
+ default:
+ setGeoLead(t("geo.lead"));
+ break;
+ }
+
+ applyGeoActionLabel();
+ applyGeoSecondaryButton();
+}
+
+onLangChange(() => {
+ refreshGeoCopy();
+});
+
+/* --- Disabled user profile (name modal) — kept for possible re-enable --- */
+const userProfileKey = "karczma_user_profile";
+const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000;
+
+export function initUserProfile() {
+ return;
+}
+
+export function saveUserName() {
+ const input = document.getElementById("userNameInput");
+ if (!input) return;
+ const name = input.value.trim();
+ if (!name) return;
+ const now = Date.now();
+ localStorage.setItem(
+ userProfileKey,
+ JSON.stringify({ name, firstVisit: now, lastVisit: now, declined: false })
+ );
+}
+
+export function declineUserName() {
+ localStorage.setItem(
+ userProfileKey,
+ JSON.stringify({ name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true })
+ );
+}
export function handleGeoMenuClick() {
- if (geoMenuButtonMode === "back_to_menu") {
+ if (geoUi.secondaryMode === "back_to_menu") {
document.getElementById("geoScreen")?.classList.add("hidden");
return;
}
@@ -204,49 +244,22 @@ export function bindGeoScreenButtons() {
});
}
-function configureGeoSecondaryButton(mode) {
- const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
- if (!menuOnlyBtn) return;
-
- geoMenuButtonMode = mode;
- const mainEl = menuOnlyBtn.querySelector(".geo-btn-main");
- const subEl = menuOnlyBtn.querySelector(".geo-btn-sub");
-
- if (mode === "back_to_menu") {
- menuOnlyBtn.style.display = "";
- if (mainEl) mainEl.textContent = t("geo.btn.back_menu");
- if (subEl) {
- subEl.textContent = "";
- subEl.style.display = "none";
- }
- return;
- }
-
- menuOnlyBtn.style.display = "";
- if (mainEl) mainEl.textContent = t("geo.btn.menu_main");
- if (subEl) {
- subEl.textContent = t("geo.btn.menu_sub");
- subEl.style.display = "";
- }
-}
-
export function showGeoGateForAction(action) {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
- const geoActionBtn = document.getElementById("geoActionBtn");
if (loadingScreen) loadingScreen.classList.add("hidden");
if (geoScreen) geoScreen.classList.remove("hidden");
- const feature = GEO_GATE_LABELS()[action] || t("geo.feature.other");
- setGeoLead(t("geo.lead_action", { feature }));
+ geoUi.mode = "gate";
+ geoUi.pendingAction = action;
+ geoUi.actionBusy = false;
+ geoUi.actionLabel = "check";
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
setGeoStatus("");
hideGeoInstructions();
- if (geoActionBtn) {
- setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.check"));
- }
- configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
+ setGeoActionBusy(false);
+ refreshGeoCopy();
}
setGeoGateHandler(showGeoGateForAction);
@@ -400,31 +413,33 @@ export function startApp() {
}, 25000);
}
+function isGeoPermissionDenied(error) {
+ return Number(error?.code) === 1;
+}
+
function showGeoConsentScreen() {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
- const geoActionBtn = document.getElementById("geoActionBtn");
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
- setGeoLead(GEO_DEFAULT_LEAD());
+ geoUi.mode = "consent";
+ geoUi.pendingAction = null;
+ geoUi.actionLabel = "locate";
+ geoUi.secondaryMode = "menu_only";
setGeoStatus("");
hideGeoInstructions();
-
- if (geoActionBtn) {
- setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.locate"));
- }
- configureGeoSecondaryButton("menu_only");
+ setGeoActionBusy(false);
+ refreshGeoCopy();
}
function showGeoPermissionBlockedState() {
- setGeoStatus(t("geo.status.blocked"), { error: true });
- showGeoInstructions(getGeoPermissionInstructions());
+ geoUi.mode = "blocked";
+ geoUi.actionLabel = "retry";
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.retry"));
- configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
+ refreshGeoCopy();
}
export function shouldBypassGeolocationHost() {
@@ -517,29 +532,27 @@ export async function initGeolocationAfterBypassChecks(options = {}) {
loadingScreen?.classList.add("hidden");
geoScreen?.classList.remove("hidden");
- configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
if (!window.isSecureContext) {
- setGeoLead(GEO_DEFAULT_LEAD());
- setGeoStatus(t("geo.status.https"), { error: true });
- showGeoInstructions(t("geo.hint.https"));
+ geoUi.mode = "https";
+ geoUi.actionLabel = "retry";
setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.retry"));
+ refreshGeoCopy();
return;
}
if (!navigator.geolocation) {
- setGeoLead(GEO_DEFAULT_LEAD());
- setGeoStatus(t("geo.status.unsupported"), { error: true });
- hideGeoInstructions();
+ geoUi.mode = "unsupported";
+ geoUi.actionLabel = "retry";
setGeoActionBusy(false);
+ refreshGeoCopy();
return;
}
- setGeoLead(GEO_DEFAULT_LEAD());
- setGeoStatus(t("geo.status.checking"), { info: true });
- hideGeoInstructions();
+ geoUi.mode = "checking";
setGeoActionBusy(true);
+ refreshGeoCopy();
const permissionState = await queryGeolocationPermissionState();
if (permissionState === "denied") {
@@ -573,36 +586,35 @@ export async function initGeolocationAfterBypassChecks(options = {}) {
distanceMeters: Math.round(dist),
accuracyMeters: Math.round(accuracy),
});
+ geoUi.mode = "outside";
+ geoUi.outsideDist = Math.round(dist);
+ geoUi.outsideAccuracy = Math.round(accuracy);
+ geoUi.actionLabel = "retry";
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.retry"));
- configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
- setGeoStatus(
- t("geo.status.outside", {
- dist: Math.round(dist),
- accuracy: Math.round(accuracy),
- }),
- { error: true }
- );
- showGeoInstructions(t("geo.hint.outside"));
+ refreshGeoCopy();
} catch (error) {
trackEvent("geo_check_failed", {
reason: "browser_error",
code: error.code || null,
message: String(error.message || ""),
});
- setGeoActionBusy(false);
- setGeoActionLabel(t("geo.btn.retry"));
- configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || ""));
if (deniedBecauseInsecure) {
- setGeoStatus(t("geo.status.https_short"), { error: true });
- showGeoInstructions(t("geo.hint.https"));
+ geoUi.mode = "https";
+ geoUi.actionLabel = "retry";
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
+ setGeoActionBusy(false);
+ refreshGeoCopy();
} else if (isGeoPermissionDenied(error)) {
showGeoPermissionBlockedState();
} else {
- setGeoStatus(t("geo.status.failed"), { error: true });
- showGeoInstructions(t("geo.hint.retry"));
+ geoUi.mode = "failed";
+ geoUi.actionLabel = "retry";
+ geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
+ setGeoActionBusy(false);
+ refreshGeoCopy();
}
}
}
diff --git a/public/assets/js/modules/i18n.js b/public/assets/js/modules/i18n.js
index b6edce3..5b1da00 100644
--- a/public/assets/js/modules/i18n.js
+++ b/public/assets/js/modules/i18n.js
@@ -4,10 +4,18 @@ import de from "../locales/de.js";
const STORAGE_KEY = "karczma_lang";
const catalogs = { pl, en, de };
+const LANG_BUSY_MIN_MS = 280;
let currentLang = "pl";
-/** @type {Set<(lang: string) => void>} */
+let langBusy = false;
+/** @type {Set<(lang: string) => void | Promise${t("menu.no_image")}
${escapeHtml(t("menu.no_image"))}
${t("menu.load_error")}
`; + container.innerHTML = `${escapeHtml(t("menu.load_error"))}
`; +} + +function getActiveCategoryId() { + return ( + document.querySelector(".menu-categories-nav a.active")?.getAttribute("data-category-badge") || + "0" + ); } export async function loadMenu(lang = getLang()) { const container = document.getElementById("menuContainer"); + const previousCategoryId = getActiveCategoryId(); try { const response = await fetch(`${endpoints.menu}?lang=${encodeURIComponent(lang)}`); if (!response.ok) throw new Error(t("menu.load_error")); @@ -292,17 +312,19 @@ export async function loadMenu(lang = getLang()) { catDiv.className = "rm-category"; catDiv.setAttribute("data-cat-id", catId); - let html = `${section.categoryName || ""}
+ let html = `${escapeHtml(section.categoryName || "")}
`;
items.forEach((item) => {
+ const categoryId = escapeAttr(item.categoryId);
+ const position = escapeAttr(item.position);
html += `
-
+
${renderMenuListImage(item.image)}
`;
});
@@ -313,7 +335,12 @@ export async function loadMenu(lang = getLang()) {
});
renderCategoryNav(payload.categories);
- showCategory(0);
+
+ const safePrev = String(previousCategoryId).replace(/"/g, "");
+ const categoryStillExists =
+ safePrev === "0" ||
+ !!document.querySelector(`.menu-categories-nav a[data-category-badge="${safePrev}"]`);
+ showCategory(categoryStillExists ? previousCategoryId : 0);
const searchInput = document.getElementById("menuSearchInput");
if (searchInput?.value) {
@@ -325,13 +352,7 @@ export async function loadMenu(lang = getLang()) {
}
}
-export function setMenuLanguageReload() {
- if (menuLangChangeBound) return;
- menuLangChangeBound = true;
- onLangChange(() => loadMenu());
-}
-
-setMenuLanguageReload();
+onLangChange((lang) => loadMenu(lang));
export function openItemModal(categoryId, position) {
itemModalKeys = buildVisibleMenuItemKeys();
@@ -370,7 +391,8 @@ export function closeItemModal() {
}
export function filterMenu() {
- const query = document.getElementById("menuSearchInput").value.toLowerCase();
+ const searchInput = document.getElementById("menuSearchInput");
+ const query = (searchInput?.value || "").toLowerCase();
const now = Date.now();
if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) {
window.lastMenuSearchEventAt = now;
@@ -383,7 +405,7 @@ export function filterMenu() {
const items = category.querySelectorAll(".rmc-position");
items.forEach((item) => {
- const title = item.querySelector(".rmc-title h4").textContent.toLowerCase();
+ const title = item.querySelector(".rmc-title h4")?.textContent?.toLowerCase() || "";
if (title.includes(query)) {
item.style.display = "";
hasVisibleItems = true;
diff --git a/public/assets/js/modules/orders.js b/public/assets/js/modules/orders.js
index 6bd11d7..e742721 100644
--- a/public/assets/js/modules/orders.js
+++ b/public/assets/js/modules/orders.js
@@ -1,5 +1,5 @@
import { endpoints, loaderMinMs } from "./config.js";
-import { getLang, t } from "./i18n.js";
+import { getLang, onLangChange, t } from "./i18n.js";
import { runPendingProtectedAction, updateNavAccessState } from "./access.js";
import {
refreshGuestPendingActions,
@@ -31,6 +31,19 @@ const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
const LOADER_MIN_MS = loaderMinMs;
const loadStartTime = Date.now();
+/** @type {Array} */
+let lastBillsForUi = [];
+let hasRenderedOrders = false;
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
function getLoaderMsgs() {
return [t("loader.msg1"), t("loader.msg2"), t("loader.msg3"), t("loader.msg4")];
}
@@ -56,6 +69,20 @@ if (getTableParam()) {
tableLabel.textContent = formatTableLabel(getTableParam());
}
+onLangChange(() => {
+ if (tableLabel && getTableParam()) {
+ tableLabel.textContent = formatTableLabel(getTableParam());
+ }
+
+ if (loaderMsg && loadingScreen && !loadingScreen.classList.contains("hidden")) {
+ loaderMsg.textContent = getLoaderMsgs()[msgIdx];
+ }
+
+ if (hasRenderedOrders) {
+ updateUI(lastBillsForUi, { manageLoader: false });
+ }
+});
+
/** Storage key must follow current hash (fallback: table), not a frozen empty tableParam at init. */
export function getOrderStorageKey() {
const key = (getHashParam() || getTableParam() || "unknown").toLowerCase();
@@ -70,7 +97,7 @@ export function hideLoader() {
clearInterval(msgInterval);
const bottomNav = document.getElementById("bottomNav");
if (bottomNav) {
- bottomNav.style.display = "";
+ bottomNav.hidden = false;
}
updateNavAccessState();
if (getPendingProtectedAction() && getAppAccessLevel() === "full") {
@@ -94,11 +121,15 @@ export async function resolveTableLabel() {
}
}
-export function updateUI(bills) {
- // Hide loader after minimum display time
- hideLoader();
+export function updateUI(bills, { manageLoader = true } = {}) {
+ lastBillsForUi = Array.isArray(bills) ? bills : [];
+ hasRenderedOrders = true;
- const allArticles = bills.flatMap((b) => (Array.isArray(b?.Articles) ? b.Articles : []));
+ if (manageLoader) {
+ hideLoader();
+ }
+
+ const allArticles = lastBillsForUi.flatMap((b) => (Array.isArray(b?.Articles) ? b.Articles : []));
const items = mergeWithPersistedItems(allArticles);
renderGlobalHistory();
@@ -108,7 +139,7 @@ export function updateUI(bills) {
}
renderItems(items);
- updateStatus(bills, items);
+ updateStatus(lastBillsForUi, items);
}
function showEmptyState() {
@@ -235,14 +266,14 @@ export function renderGlobalHistory() {
div.className = "item-card archived ready";
div.innerHTML = `
-
- ${item.title}${item.description || ""}
+${escapeHtml(item.title)}${escapeHtml(item.description || "")}
${item.price}
+ ${escapeHtml(item.price)}
- ${entry.name}
- ${t("history.entry_meta", {
+ ${escapeHtml(entry.name)}
+ ${escapeHtml(t("history.entry_meta", {
table: formatTableLabel(entry.sourceTable || "?"),
date: dt.toLocaleDateString(lang),
time: dt.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }),
- })}
+ }))}
- x${entry.qty}
+ x${escapeHtml(entry.qty)}
`;
historyList.appendChild(div);
});
@@ -326,10 +357,10 @@ function renderItems(items) {
div.innerHTML = `
- ${item.name}
- ${meta}
+ ${escapeHtml(item.name)}
+ ${escapeHtml(meta)}
- x${item.qty}
+ x${escapeHtml(item.qty)}
`;
itemsList.appendChild(div);
});
diff --git a/public/staff/auth.php b/public/staff/auth.php
index aa310f8..7a7675d 100644
--- a/public/staff/auth.php
+++ b/public/staff/auth.php
@@ -26,13 +26,26 @@ function requireAdminAuth(bool $redirectToLogin = true): void
header('Location: login.php');
exit;
}
+
+ http_response_code(401);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => 'Unauthorized',
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
}
function attemptAdminLogin(string $username, string $password): bool
{
startAdminSession();
- if (hash_equals(ADMIN_USERNAME, $username) && hash_equals(ADMIN_PASSWORD, $password)) {
+ $userOk = hash_equals(ADMIN_USERNAME, $username);
+ $passOk = strlen($password) === strlen(ADMIN_PASSWORD)
+ && hash_equals(ADMIN_PASSWORD, $password);
+
+ if ($userOk && $passOk) {
+ session_regenerate_id(true);
$_SESSION['staff_logged_in'] = true;
$_SESSION['staff_username'] = ADMIN_USERNAME;
return true;
diff --git a/public/waiter/app.js b/public/waiter/app.js
index 9f0cd4d..2688b54 100644
--- a/public/waiter/app.js
+++ b/public/waiter/app.js
@@ -1,5 +1,6 @@
const API_URL = '../../api/waiter_feed.php';
const POLL_MS = 15000;
+const FEED_TOKEN = String(window.WAITER_CONFIG?.feedToken || '');
const feedList = document.getElementById('feedList');
const emptyState = document.getElementById('emptyState');
@@ -238,7 +239,10 @@ function setSyncState(ok, message) {
async function pollFeed() {
try {
- const response = await fetch(API_URL, { cache: 'no-store' });
+ const response = await fetch(API_URL, {
+ cache: 'no-store',
+ headers: FEED_TOKEN ? { 'X-Waiter-Token': FEED_TOKEN } : {},
+ });
const result = await response.json();
if (result.status !== 'success') {
diff --git a/public/waiter/index.php b/public/waiter/index.php
index eb314d4..ffc64d4 100644
--- a/public/waiter/index.php
+++ b/public/waiter/index.php
@@ -3,6 +3,8 @@ require_once __DIR__ . '/../includes/asset_version.php';
$waiterDir = __DIR__;
$vCss = publicAssetVersion($waiterDir, 'app.css');
$vJs = publicAssetVersion($waiterDir, 'app.js');
+$waiterConfig = require __DIR__ . '/../../config/waiter.php';
+$waiterFeedToken = (string) ($waiterConfig['feed_token'] ?? '');
?>
@@ -19,6 +21,9 @@ $vJs = publicAssetVersion($waiterDir, 'app.js');
+