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
+4
View File
@@ -1 +1,5 @@
node_modules/
.vscode/sftp.json
.env
.env.*
config/*.local.php
+7
View File
@@ -0,0 +1,7 @@
# Blokuj publiczny dostęp do cache (mapowanie QR → stoliki, menu JSON).
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
+10 -7
View File
@@ -19,14 +19,17 @@ function resolveGuestQueueTableId(string $tableId, string $qrHash): string
{
global $conn;
if ($tableId === '' && $qrHash !== '' && isset($conn)) {
$resolved = getTableNameByHash($conn, $qrHash);
if ($resolved !== '') {
$tableId = $resolved;
}
// Wymagamy prawidłowego hasha QR — samo tableId (łatwe do odgadnięcia) nie wystarczy.
if ($qrHash === '' || !isset($conn)) {
return '';
}
return trim($tableId);
$resolved = getTableNameByHash($conn, $qrHash);
if ($resolved === '') {
return '';
}
return trim($resolved);
}
function hasPendingGuestAction(PDO $pdo, string $tableId, string $messageType): bool
@@ -285,7 +288,7 @@ if ($tableId === '') {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'tableId is required',
'message' => 'Valid qrHash is required',
], JSON_UNESCAPED_UNICODE);
exit;
}
+40 -6
View File
@@ -1,17 +1,51 @@
<?php
/**
* Adres IP klienta (pierwszy z X-Forwarded-For lub REMOTE_ADDR).
* Czy REMOTE_ADDR to lokalny hop / reverse proxy, któremu wolno
* przekazać prawdziwy IP klienta w X-Forwarded-For.
*/
function isTrustedForwardingHop(string $remoteAddr): bool
{
$ip = normalizeClientIp($remoteAddr);
if ($ip === '') {
return false;
}
if (in_array($ip, ['127.0.0.1', '::1'], true)) {
return true;
}
// Proxy / Apache na LAN restauracji (goście Wi‑Fi widziani jako 10.x w XFF)
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
foreach (['10.0.0.0/8', '192.168.0.0/16', '172.16.0.0/12'] as $cidr) {
if (ipv4InCidr($ip, $cidr)) {
return true;
}
}
}
return false;
}
/**
* Adres IP klienta.
* X-Forwarded-For uznajemy wyłącznie, gdy połączenie przychodzi z zaufanego
* hopa (localhost / LAN) — inaczej każdy w internecie mógłby podrobić IP
* i ominąć geo. Bezpośredni dostęp z internetu = samo REMOTE_ADDR.
*/
function getRequestClientIp(): string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
$remote = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
return trim($parts[0]);
if (isTrustedForwardingHop($remote) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
$forwarded = normalizeClientIp(trim($parts[0]));
if ($forwarded !== '' && filter_var($forwarded, FILTER_VALIDATE_IP)) {
return $forwarded;
}
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
return normalizeClientIp($remote);
}
/**
@@ -43,7 +77,7 @@ function getGeoBypassTrustedIps(): array
}
/**
* Pule wewnętrznych sieci — goście widziani przez lokalny serwer (REMOTE_ADDR z LAN).
* Pule wewnętrznych sieci — goście na Wi‑Fi restauracji.
*/
function getGeoBypassTrustedCidrs(): array
{
+19
View File
@@ -4,6 +4,25 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/message_text_helper.php';
$waiterConfig = require __DIR__ . '/../config/waiter.php';
$expectedToken = (string) ($waiterConfig['feed_token'] ?? '');
$providedToken = '';
if (isset($_SERVER['HTTP_X_WAITER_TOKEN'])) {
$providedToken = trim((string) $_SERVER['HTTP_X_WAITER_TOKEN']);
} elseif (isset($_GET['token'])) {
$providedToken = trim((string) $_GET['token']);
}
if ($expectedToken === '' || !hash_equals($expectedToken, $providedToken)) {
http_response_code(401);
echo json_encode([
'status' => 'error',
'message' => 'Unauthorized',
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
/**
* Token dostępu do panelu kelnera (waiter_feed).
* Wstrzykiwany do public/waiter/index.php — nie trzymaj go w statycznym app.js.
*/
return [
'feed_token' => 'biesiada_waiter_feed_2026',
];
+103 -102
View File
@@ -24,7 +24,7 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<!-- build: css=<?= assetVersionAttr($vCss) ?> js=<?= assetVersionAttr($vJs) ?> -->
@@ -54,18 +54,18 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
</div>
<div class="geo-icon">📍</div>
<h2 class="geo-title" data-i18n="geo.title">Witamy w Karcznie</h2>
<p class="geo-lead" id="geoLead" data-i18n="geo.lead">
<p class="geo-lead" id="geoLead">
Przeglądaj menu od razu — albo potwierdź, że jesteś u nas, aby wezwać kelnera, śledzić zamówienie i poprosić o rachunek.
</p>
<p class="geo-status" id="geoMsg"></p>
<div class="geo-actions" id="geoActions">
<button type="button" id="geoMenuOnlyBtn" class="geo-btn geo-btn-menu">
<span class="geo-btn-main" data-i18n="geo.btn.menu_main">Przejdź do menu</span>
<span class="geo-btn-sub" data-i18n="geo.btn.menu_sub">bez lokalizacji</span>
<span class="geo-btn-main">Przejdź do menu</span>
<span class="geo-btn-sub">bez lokalizacji</span>
</button>
<button type="button" id="geoActionBtn" class="geo-btn geo-btn-locate btn btn-primary">
<span class="geo-btn-main" data-i18n="geo.btn.locate">Zgoda, sprawdź lokalizację</span>
<span class="geo-btn-main">Zgoda, sprawdź lokalizację</span>
</button>
</div>
@@ -87,114 +87,115 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
</div>
</div>
<div class="container">
<header id="mainHeader">
<div class="header-top">
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="headerLangPickerMount"></div>
</div>
<div id="tableLabel" class="table-badge" data-i18n="table.choose">Wybierz stolik</div>
</header>
<div class="app-shell" id="appShell">
<div class="app-scroll" id="appScroll">
<div class="container">
<header id="mainHeader">
<div class="header-top">
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="headerLangPickerMount"></div>
</div>
<div id="tableLabel" class="table-badge" data-i18n="table.choose">Wybierz stolik</div>
</header>
<main id="mainContent">
<div id="statusView" class="view-section active">
<main id="mainContent">
<div id="statusView" class="view-section active">
<section class="status-card">
<div class="status-header">
<div>
<span class="status-title" data-i18n="status.title">Aktualny status</span>
<div id="prepStatus" class="status-value" data-i18n="status.waiting">Oczekiwanie...</div>
<section class="status-card">
<div class="status-header">
<div>
<span class="status-title" data-i18n="status.title">Aktualny status</span>
<div id="prepStatus" class="status-value" data-i18n="status.waiting">Oczekiwanie...</div>
</div>
<div id="statusIcon" style="font-size: 28px;">⏳</div>
</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="statusMeta" style="font-size: 12px; color: var(--text-muted);" data-i18n="status.checking">
Sprawdzamy co pysznego się przygotowuje...
</div>
</section>
<section class="items-container" id="ordersContainer">
<h3 id="ordersTitle" data-i18n="orders.title">Twoje zamówione dania</h3>
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon">📖</div>
<p style="color: var(--text-muted)" data-i18n="orders.empty">Jeśli właśnie złożyłeś zamówienie, daj nam chwilkę na jego
przetworzenie.</p>
<button class="btn btn-primary" style="margin-top: 15px; padding: 12px 20px; font-size: 15px;"
onclick="switchTab('menu')" data-i18n="orders.browse_menu">Przeglądaj menu</button>
</div>
<div id="itemsList"></div>
</section>
<section id="historySection" class="items-container history-section hidden">
<h3 data-i18n="history.title">Twoje poprzednie zamówienia</h3>
<p class="history-note" data-i18n="history.note">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 🙂</p>
<div id="historyList"></div>
<div style="text-align: center; margin-top: 15px;">
<a href="#" onclick="clearGlobalHistory(event)"
style="font-size: 12px; color: var(--text-muted); text-decoration: underline;" data-i18n="history.clear">Usuń historię</a>
</div>
</section>
<div id="metaFooter" class="meta-footer"></div>
</div>
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner is-hidden">
<span data-i18n="menu.banner">Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()" data-i18n="menu.banner_btn">Sprawdź teraz</button>
</div>
<div class="menu-search-container">
<input type="text" id="menuSearchInput" data-i18n-placeholder="menu.search" placeholder="Szukaj dania..." oninput="filterMenu()" />
<div id="menuLangPickerMount"></div>
</div>
<div class="restaurant-menu-container">
<nav class="menu-categories-nav">
<ul>
<!-- Dynamic categories -->
</ul>
</nav>
<div class="restaurant-menu-scroll" id="menuContainer">
<!-- Dynamiczne menu załaduje się tutaj -->
</div>
</div>
<div id="statusIcon" style="font-size: 28px;">⏳</div>
</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
</main>
<div id="statusMeta" style="font-size: 12px; color: var(--text-muted);" data-i18n="status.checking">
Sprawdzamy co pysznego się przygotowuje...
</div>
</section>
<section class="items-container" id="ordersContainer">
<h3 id="ordersTitle" data-i18n="orders.title">Twoje zamówione dania</h3>
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon">📖</div>
<p style="color: var(--text-muted)" data-i18n="orders.empty">Jeśli właśnie złożyłeś zamówienie, daj nam chwilkę na jego
przetworzenie.</p>
<button class="btn btn-primary" style="margin-top: 15px; padding: 12px 20px; font-size: 15px;"
onclick="switchTab('menu')" data-i18n="orders.browse_menu">Przeglądaj menu</button>
</div>
<div id="itemsList"></div>
</section>
<section id="historySection" class="items-container history-section hidden">
<h3 data-i18n="history.title">Twoje poprzednie zamówienia</h3>
<p class="history-note" data-i18n="history.note">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 🙂</p>
<div id="historyList"></div>
<div style="text-align: center; margin-top: 15px;">
<a href="#" onclick="clearGlobalHistory(event)"
style="font-size: 12px; color: var(--text-muted); text-decoration: underline;" data-i18n="history.clear">Usuń historię</a>
</div>
</section>
<div id="metaFooter" class="meta-footer"></div>
<footer class="app-footer">
<a href="https://magico.pl" target="_blank">&copy; Magico Software</a>
</footer>
</div>
</div>
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner is-hidden">
<span data-i18n="menu.banner">Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()" data-i18n="menu.banner_btn">Sprawdź teraz</button>
</div>
<div class="menu-search-container">
<input type="text" id="menuSearchInput" data-i18n-placeholder="menu.search" placeholder="Szukaj dania..." oninput="filterMenu()" />
<div id="menuLangPickerMount"></div>
</div>
<div class="restaurant-menu-container">
<nav class="menu-categories-nav">
<ul>
<!-- Dynamic categories -->
</ul>
</nav>
<div class="restaurant-menu-scroll" id="menuContainer">
<!-- Dynamiczne menu załaduje się tutaj -->
</div>
</div>
<nav class="bottom-nav" id="bottomNav" hidden>
<div class="nav-item active" onclick="switchTab('status')" id="navStatus">
<span class="nav-icon">🍽️</span>
<span class="nav-label" data-i18n="nav.order">Zamówienie</span>
</div>
</main>
<footer style="text-align: center; padding: 10px 0 20px; margin-top: 5px;">
<a href="https://magico.pl" target="_blank"
style="font-size: 12px; color: var(--text-muted); text-decoration: none;">
&copy; Magico Software
</a>
</footer>
<div class="nav-item" onclick="switchTab('menu')" id="navMenu">
<span class="nav-icon">📖</span>
<span class="nav-label" data-i18n="nav.menu">Menu</span>
</div>
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
<span class="nav-icon">🛎️</span>
<span class="nav-label" data-i18n="nav.waiter">Kelner</span>
</div>
<div class="nav-item action-bill" onclick="openBillDialog()" id="navBill">
<span class="nav-icon">💳</span>
<span class="nav-label" data-i18n="nav.bill">Rachunek</span>
</div>
</nav>
</div>
<nav class="bottom-nav" id="bottomNav" style="display: none;">
<div class="nav-item active" onclick="switchTab('status')" id="navStatus">
<span class="nav-icon">🍽️</span>
<span class="nav-label" data-i18n="nav.order">Zamówienie</span>
</div>
<div class="nav-item" onclick="switchTab('menu')" id="navMenu">
<span class="nav-icon">📖</span>
<span class="nav-label" data-i18n="nav.menu">Menu</span>
</div>
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
<span class="nav-icon">🛎️</span>
<span class="nav-label" data-i18n="nav.waiter">Kelner</span>
</div>
<div class="nav-item action-bill" onclick="openBillDialog()" id="navBill">
<span class="nav-icon">💳</span>
<span class="nav-label" data-i18n="nav.bill">Rachunek</span>
</div>
</nav>
<div class="modal-overlay" id="waiterModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">🛎️</div>
+131 -38
View File
@@ -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;
+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, "&amp;")
.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);
});
+14 -1
View File
@@ -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;
+5 -1
View File
@@ -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') {
+5
View File
@@ -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'] ?? '');
?><!DOCTYPE html>
<html lang="pl">
<head>
@@ -19,6 +21,9 @@ $vJs = publicAssetVersion($waiterDir, 'app.js');
<link rel="apple-touch-icon" href="icons/icon-192.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="app.css?v=<?= assetVersionAttr($vCss) ?>">
<script>
window.WAITER_CONFIG = <?= json_encode(['feedToken' => $waiterFeedToken], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
</script>
</head>
<body>
<header class="top-bar">