Poprawka botto bar i wersje językowe

This commit is contained in:
2026-09-25 17:39:44 +02:00
parent 3056eb6c3d
commit a381ad09d8
21 changed files with 770 additions and 428 deletions
+1
View File
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Gesendet!",
"lang.aria": "Sprache wählen",
"lang.loading": "Sprache wird geladen…",
};
+1
View File
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Sent!",
"lang.aria": "Choose language",
"lang.loading": "Loading language…",
};
+1
View File
@@ -142,4 +142,5 @@ export default {
"toast.sent": "Wysłano!",
"lang.aria": "Wybierz język",
"lang.loading": "Ładowanie języka…",
};
+3 -1
View File
@@ -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) {
+13 -28
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 = `
<div>
<div style="font-weight:bold;">${numerFormat}</div>
<div style="font-size:12px; color:var(--text-muted);">${b.opis}</div>
<div style="font-weight:bold;">${escapeHtml(numerFormat)}</div>
<div style="font-size:12px; color:var(--text-muted);">${escapeHtml(b.opis)}</div>
</div>
<div style="font-weight:bold; color:var(--primary);">${b.suma.toFixed(2)} PLN</div>
<div style="font-weight:bold; color:var(--primary);">${Number(b.suma).toFixed(2)} PLN</div>
`;
container.appendChild(div);
});
@@ -172,10 +171,10 @@ export function showBillReview(bill) {
div.innerHTML = `
<div style="flex:1;">
<div style="font-weight:600; font-size: 14px;">${p.nazwa}</div>
<div style="font-size:12px; color:var(--text-muted);">${p.ilosc} x ${p.cena.toFixed(2)} PLN</div>
<div style="font-weight:600; font-size: 14px;">${escapeHtml(p.nazwa)}</div>
<div style="font-size:12px; color:var(--text-muted);">${escapeHtml(p.ilosc)} x ${Number(p.cena).toFixed(2)} PLN</div>
</div>
<div style="font-weight:600;">${p.wartosc.toFixed(2)} PLN</div>
<div style="font-weight:600;">${Number(p.wartosc).toFixed(2)} PLN</div>
`;
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"));
}
-8
View File
@@ -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;
+195 -183
View File
@@ -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();
}
}
}
+120 -19
View File
@@ -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<void>>} */
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 `<img class="lang-flag-img" src="assets/img/flags/${safe}.svg" alt="" width="28" height="20" decoding="async">`;
}
function spinnerHtml() {
return `<span class="lang-picker-spinner" aria-hidden="true"></span>`;
}
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)}<span>${lang.label}</span>`;
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 };
}
+42 -20
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
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 `<div class="rmc-image-wrap"><img class="${imgClass}"${srcAttr} alt="" loading="lazy"${onerror}><div class="${placeholderClass}" aria-hidden="true"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span>${t("menu.no_image")}</span></div></div>`;
return `<div class="rmc-image-wrap"><img class="${imgClass}"${srcAttr} alt="" loading="lazy"${onerror}><div class="${placeholderClass}" aria-hidden="true"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span>${escapeHtml(t("menu.no_image"))}</span></div></div>`;
}
function findMenuItem(categoryId, position) {
@@ -250,13 +262,13 @@ function renderCategoryNav(categories) {
if (!ul) return;
const list = Array.isArray(categories) ? categories : [];
let html = `<li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0); return false;">${t("menu.all")}</a></li>`;
let html = `<li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0); return false;">${escapeHtml(t("menu.all"))}</a></li>`;
list.forEach((cat) => {
const id = String(cat?.id ?? "").trim();
const name = String(cat?.name ?? "").trim();
if (!id || !name) return;
html += `<li><a href="#" data-category-badge="${id}" onclick="showCategory('${id}'); return false;">${name}</a></li>`;
html += `<li><a href="#" data-category-badge="${escapeAttr(id)}" onclick="showCategory('${escapeAttr(id)}'); return false;">${escapeHtml(name)}</a></li>`;
});
ul.innerHTML = html;
@@ -264,11 +276,19 @@ function renderCategoryNav(categories) {
function showMenuLoadError(container) {
if (!container) return;
container.innerHTML = `<p style="text-align:center; padding: 20px; color: var(--text-muted);">${t("menu.load_error")}</p>`;
container.innerHTML = `<p style="text-align:center; padding: 20px; color: var(--text-muted);">${escapeHtml(t("menu.load_error"))}</p>`;
}
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 = `<div class="restaurant-menu-category">${section.categoryName || ""}</div>
let html = `<div class="restaurant-menu-category">${escapeHtml(section.categoryName || "")}</div>
<div class="rmc-positions">`;
items.forEach((item) => {
const categoryId = escapeAttr(item.categoryId);
const position = escapeAttr(item.position);
html += `
<div class="rmc-position" data-position="${item.position}" data-category-id="${item.categoryId}" onclick="openItemModal('${item.categoryId}', '${item.position}')" style="cursor: pointer;">
<div class="rmc-position" data-position="${position}" data-category-id="${categoryId}" onclick="openItemModal('${categoryId}', '${position}')" style="cursor: pointer;">
${renderMenuListImage(item.image)}
<div class="rmc-title">
<h4>${item.title}<span>${item.description || ""}</span></h4>
<h4>${escapeHtml(item.title)}<span>${escapeHtml(item.description || "")}</span></h4>
</div>
<div class="rmc-other"><span>${item.price}</span></div>
<div class="rmc-other"><span>${escapeHtml(item.price)}</span></div>
</div>
`;
});
@@ -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;
+45 -14
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 = `
<div class="item-info">
<span class="item-name">${entry.name}</span>
<span class="item-meta">${t("history.entry_meta", {
<span class="item-name">${escapeHtml(entry.name)}</span>
<span class="item-meta">${escapeHtml(t("history.entry_meta", {
table: formatTableLabel(entry.sourceTable || "?"),
date: dt.toLocaleDateString(lang),
time: dt.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }),
})}</span>
}))}</span>
</div>
<div class="item-qty">x${entry.qty}</div>
<div class="item-qty">x${escapeHtml(entry.qty)}</div>
`;
historyList.appendChild(div);
});
@@ -326,10 +357,10 @@ function renderItems(items) {
div.innerHTML = `
<div class="item-info">
<span class="item-name">${item.name}</span>
<span class="item-meta">${meta}</span>
<span class="item-name">${escapeHtml(item.name)}</span>
<span class="item-meta">${escapeHtml(meta)}</span>
</div>
<div class="item-qty">x${item.qty}</div>
<div class="item-qty">x${escapeHtml(item.qty)}</div>
`;
itemsList.appendChild(div);
});