Poprawka botto bar i wersje językowe
This commit is contained in:
@@ -1 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.vscode/sftp.json
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
config/*.local.php
|
||||||
|
|||||||
Vendored
+7
@@ -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>
|
||||||
@@ -19,14 +19,17 @@ function resolveGuestQueueTableId(string $tableId, string $qrHash): string
|
|||||||
{
|
{
|
||||||
global $conn;
|
global $conn;
|
||||||
|
|
||||||
if ($tableId === '' && $qrHash !== '' && isset($conn)) {
|
// Wymagamy prawidłowego hasha QR — samo tableId (łatwe do odgadnięcia) nie wystarczy.
|
||||||
$resolved = getTableNameByHash($conn, $qrHash);
|
if ($qrHash === '' || !isset($conn)) {
|
||||||
if ($resolved !== '') {
|
return '';
|
||||||
$tableId = $resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return trim($tableId);
|
$resolved = getTableNameByHash($conn, $qrHash);
|
||||||
|
if ($resolved === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPendingGuestAction(PDO $pdo, string $tableId, string $messageType): bool
|
function hasPendingGuestAction(PDO $pdo, string $tableId, string $messageType): bool
|
||||||
@@ -285,7 +288,7 @@ if ($tableId === '') {
|
|||||||
http_response_code(422);
|
http_response_code(422);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'tableId is required',
|
'message' => 'Valid qrHash is required',
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-6
@@ -1,17 +1,51 @@
|
|||||||
<?php
|
<?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
|
function getRequestClientIp(): string
|
||||||
{
|
{
|
||||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
$remote = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
||||||
|
|
||||||
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
|
function getGeoBypassTrustedCidrs(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ header('Content-Type: application/json; charset=utf-8');
|
|||||||
require_once __DIR__ . '/../config/database.php';
|
require_once __DIR__ . '/../config/database.php';
|
||||||
require_once __DIR__ . '/message_text_helper.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') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
|
|||||||
@@ -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
@@ -24,7 +24,7 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
<meta http-equiv="Pragma" content="no-cache" />
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<!-- build: css=<?= assetVersionAttr($vCss) ?> js=<?= assetVersionAttr($vJs) ?> -->
|
<!-- build: css=<?= assetVersionAttr($vCss) ?> js=<?= assetVersionAttr($vJs) ?> -->
|
||||||
@@ -54,18 +54,18 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="geo-icon">📍</div>
|
<div class="geo-icon">📍</div>
|
||||||
<h2 class="geo-title" data-i18n="geo.title">Witamy w Karcznie</h2>
|
<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.
|
Przeglądaj menu od razu — albo potwierdź, że jesteś u nas, aby wezwać kelnera, śledzić zamówienie i poprosić o rachunek.
|
||||||
</p>
|
</p>
|
||||||
<p class="geo-status" id="geoMsg"></p>
|
<p class="geo-status" id="geoMsg"></p>
|
||||||
|
|
||||||
<div class="geo-actions" id="geoActions">
|
<div class="geo-actions" id="geoActions">
|
||||||
<button type="button" id="geoMenuOnlyBtn" class="geo-btn geo-btn-menu">
|
<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-main">Przejdź do menu</span>
|
||||||
<span class="geo-btn-sub" data-i18n="geo.btn.menu_sub">bez lokalizacji</span>
|
<span class="geo-btn-sub">bez lokalizacji</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="geoActionBtn" class="geo-btn geo-btn-locate btn btn-primary">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,114 +87,115 @@ foreach (($menuConfig['languages'] ?? []) as $code => $meta) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="app-shell" id="appShell">
|
||||||
<header id="mainHeader">
|
<div class="app-scroll" id="appScroll">
|
||||||
<div class="header-top">
|
<div class="container">
|
||||||
<h1 class="logo-text">Karczma Biesiada</h1>
|
<header id="mainHeader">
|
||||||
<div id="headerLangPickerMount"></div>
|
<div class="header-top">
|
||||||
</div>
|
<h1 class="logo-text">Karczma Biesiada</h1>
|
||||||
<div id="tableLabel" class="table-badge" data-i18n="table.choose">Wybierz stolik</div>
|
<div id="headerLangPickerMount"></div>
|
||||||
</header>
|
</div>
|
||||||
|
<div id="tableLabel" class="table-badge" data-i18n="table.choose">Wybierz stolik</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main id="mainContent">
|
<main id="mainContent">
|
||||||
<div id="statusView" class="view-section active">
|
<div id="statusView" class="view-section active">
|
||||||
|
|
||||||
<section class="status-card">
|
<section class="status-card">
|
||||||
<div class="status-header">
|
<div class="status-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="status-title" data-i18n="status.title">Aktualny status</span>
|
<span class="status-title" data-i18n="status.title">Aktualny status</span>
|
||||||
<div id="prepStatus" class="status-value" data-i18n="status.waiting">Oczekiwanie...</div>
|
<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>
|
||||||
<div id="statusIcon" style="font-size: 28px;">⏳</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-container">
|
</main>
|
||||||
<div id="progressBar" class="progress-bar"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="statusMeta" style="font-size: 12px; color: var(--text-muted);" data-i18n="status.checking">
|
<footer class="app-footer">
|
||||||
Sprawdzamy co pysznego się przygotowuje...
|
<a href="https://magico.pl" target="_blank">© Magico Software</a>
|
||||||
</div>
|
</footer>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="menuView" class="view-section hidden">
|
<nav class="bottom-nav" id="bottomNav" hidden>
|
||||||
<div id="menuOnlyBanner" class="menu-only-banner is-hidden">
|
<div class="nav-item active" onclick="switchTab('status')" id="navStatus">
|
||||||
<span data-i18n="menu.banner">Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
|
<span class="nav-icon">🍽️</span>
|
||||||
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()" data-i18n="menu.banner_btn">Sprawdź teraz</button>
|
<span class="nav-label" data-i18n="nav.order">Zamówienie</span>
|
||||||
</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>
|
</div>
|
||||||
|
<div class="nav-item" onclick="switchTab('menu')" id="navMenu">
|
||||||
</main>
|
<span class="nav-icon">📖</span>
|
||||||
|
<span class="nav-label" data-i18n="nav.menu">Menu</span>
|
||||||
<footer style="text-align: center; padding: 10px 0 20px; margin-top: 5px;">
|
</div>
|
||||||
<a href="https://magico.pl" target="_blank"
|
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
|
||||||
style="font-size: 12px; color: var(--text-muted); text-decoration: none;">
|
<span class="nav-icon">🛎️</span>
|
||||||
© Magico Software
|
<span class="nav-label" data-i18n="nav.waiter">Kelner</span>
|
||||||
</a>
|
</div>
|
||||||
</footer>
|
<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>
|
</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-overlay" id="waiterModal">
|
||||||
<div class="modal-content" style="text-align: center;">
|
<div class="modal-content" style="text-align: center;">
|
||||||
<div style="font-size: 48px; margin-bottom: 15px;">🛎️</div>
|
<div style="font-size: 48px; margin-bottom: 15px;">🛎️</div>
|
||||||
|
|||||||
+131
-38
@@ -9,6 +9,8 @@
|
|||||||
--success: #4ade80;
|
--success: #4ade80;
|
||||||
--accent: #f59e0b;
|
--accent: #f59e0b;
|
||||||
--radius: 20px;
|
--radius: 20px;
|
||||||
|
--bottom-nav-height: 64px;
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -16,13 +18,19 @@
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
height: 100dvh;
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- LOADER SCREEN --- */
|
/* --- LOADER SCREEN --- */
|
||||||
@@ -97,20 +105,31 @@ body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 34px;
|
||||||
background: var(--surface, #1e293b);
|
background: var(--surface, #1e293b);
|
||||||
border: 1px solid var(--surface-light, #334155);
|
border: 1px solid var(--surface-light, #334155);
|
||||||
color: var(--text-main, #f8fafc);
|
color: var(--text-main, #f8fafc);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px 8px;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-picker-btn:hover {
|
.lang-picker-btn:hover:not(:disabled) {
|
||||||
border-color: var(--primary, #e2b07e);
|
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 {
|
.lang-flag-img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -120,6 +139,27 @@ body {
|
|||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
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 {
|
.lang-picker-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -154,6 +194,11 @@ body {
|
|||||||
background: rgba(226, 176, 126, 0.15);
|
background: rgba(226, 176, 126, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-picker-option:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.header-top {
|
.header-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -341,11 +386,40 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- MAIN LAYOUT --- */
|
/* --- MAIN LAYOUT (flex column: scroll + bottom bar) --- */
|
||||||
.container {
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
height: 100dvh;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
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 {
|
header {
|
||||||
@@ -1046,25 +1120,25 @@ header {
|
|||||||
|
|
||||||
/* --- BOTTOM NAVIGATION BAR --- */
|
/* --- BOTTOM NAVIGATION BAR --- */
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
position: fixed;
|
flex: 0 0 auto;
|
||||||
bottom: 0;
|
position: relative;
|
||||||
left: 0;
|
z-index: 20;
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
padding: 5px 10px;
|
height: calc(var(--bottom-nav-height) + var(--safe-bottom));
|
||||||
padding-bottom: env(safe-area-inset-bottom, 5px);
|
padding: 0 4px var(--safe-bottom);
|
||||||
z-index: 100;
|
background: rgba(28, 28, 31, 0.96);
|
||||||
box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3);
|
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 {
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1073,26 +1147,38 @@ header {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
width: 25%;
|
flex: 1 1 0;
|
||||||
height: 100%;
|
min-width: 0;
|
||||||
|
height: var(--bottom-nav-height);
|
||||||
|
padding: 0 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
|
display: block;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
transition: transform 0.3s;
|
line-height: 1;
|
||||||
filter: grayscale(1) opacity(0.6);
|
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 {
|
.nav-item.active {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
.nav-item.active .nav-icon {
|
||||||
transform: translateY(-2px) scale(1.1);
|
|
||||||
filter: grayscale(0) opacity(1);
|
filter: grayscale(0) opacity(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,20 +1202,31 @@ header {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.nav-action-pending .nav-label::after {
|
.nav-item.nav-action-pending::before {
|
||||||
content: " · w toku";
|
content: "";
|
||||||
font-size: 9px;
|
position: absolute;
|
||||||
font-weight: 500;
|
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 {
|
.nav-item.nav-locked {
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
filter: grayscale(0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.nav-locked .nav-label::after {
|
.nav-item.nav-locked::after {
|
||||||
content: " 🔒";
|
content: "🔒";
|
||||||
font-size: 10px;
|
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 {
|
.menu-only-banner {
|
||||||
@@ -1164,10 +1261,6 @@ header {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:active .nav-icon {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SPA View switching */
|
/* SPA View switching */
|
||||||
.view-section {
|
.view-section {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -142,4 +142,5 @@ export default {
|
|||||||
|
|
||||||
"toast.sent": "Gesendet!",
|
"toast.sent": "Gesendet!",
|
||||||
"lang.aria": "Sprache wählen",
|
"lang.aria": "Sprache wählen",
|
||||||
|
"lang.loading": "Sprache wird geladen…",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -142,4 +142,5 @@ export default {
|
|||||||
|
|
||||||
"toast.sent": "Sent!",
|
"toast.sent": "Sent!",
|
||||||
"lang.aria": "Choose language",
|
"lang.aria": "Choose language",
|
||||||
|
"lang.loading": "Loading language…",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -142,4 +142,5 @@ export default {
|
|||||||
|
|
||||||
"toast.sent": "Wysłano!",
|
"toast.sent": "Wysłano!",
|
||||||
"lang.aria": "Wybierz język",
|
"lang.aria": "Wybierz język",
|
||||||
|
"lang.loading": "Ładowanie języka…",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function updateNavAccessState() {
|
|||||||
|
|
||||||
export function showBottomNav() {
|
export function showBottomNav() {
|
||||||
const bottomNav = document.getElementById("bottomNav");
|
const bottomNav = document.getElementById("bottomNav");
|
||||||
if (bottomNav) bottomNav.style.display = "";
|
if (bottomNav) bottomNav.hidden = false;
|
||||||
updateNavAccessState();
|
updateNavAccessState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +93,8 @@ export function switchTabInternal(tabName) {
|
|||||||
if (greetingBanner) greetingBanner.style.display = "none";
|
if (greetingBanner) greetingBanner.style.display = "none";
|
||||||
}
|
}
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
const appScroll = document.getElementById("appScroll");
|
||||||
|
if (appScroll) appScroll.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function switchTab(tabName) {
|
export function switchTab(tabName) {
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import { showToast } from "./toast.js";
|
|||||||
import {
|
import {
|
||||||
getBillState,
|
getBillState,
|
||||||
getHashParam,
|
getHashParam,
|
||||||
getTableParam,
|
|
||||||
setBillState,
|
setBillState,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|
||||||
function sendApiSimulated(actionName, details) {
|
function escapeHtml(value) {
|
||||||
console.log(`[SYMULACJA API] Akcja: ${actionName}`, details);
|
return String(value ?? "")
|
||||||
// Przykładowe wysłanie docelowo:
|
.replace(/&/g, "&")
|
||||||
// if (window.socket && window.socket.readyState === WebSocket.OPEN) {
|
.replace(/</g, "<")
|
||||||
// window.socket.send(JSON.stringify({ action: "sendUpstream", payload: { type: actionName, table: tableParam, ...details } }));
|
.replace(/>/g, ">")
|
||||||
// }
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callWaiter(type) {
|
export async function callWaiter(type) {
|
||||||
@@ -48,7 +48,6 @@ export async function callWaiter(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackEvent("waiter_call_requested", { waiterType: "order" });
|
trackEvent("waiter_call_requested", { waiterType: "order" });
|
||||||
sendApiSimulated("CallWaiter_Order", { table: getTableParam() });
|
|
||||||
showToast(t("waiter.toast_ok"));
|
showToast(t("waiter.toast_ok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +141,10 @@ function renderBillList(bills) {
|
|||||||
const numerFormat = b.numer ? `#${b.numer}` : t("bill.bill_fallback");
|
const numerFormat = b.numer ? `#${b.numer}` : t("bill.bill_fallback");
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:bold;">${numerFormat}</div>
|
<div style="font-weight:bold;">${escapeHtml(numerFormat)}</div>
|
||||||
<div style="font-size:12px; color:var(--text-muted);">${b.opis}</div>
|
<div style="font-size:12px; color:var(--text-muted);">${escapeHtml(b.opis)}</div>
|
||||||
</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);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -172,10 +171,10 @@ export function showBillReview(bill) {
|
|||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-weight:600; font-size: 14px;">${p.nazwa}</div>
|
<div style="font-weight:600; font-size: 14px;">${escapeHtml(p.nazwa)}</div>
|
||||||
<div style="font-size:12px; color:var(--text-muted);">${p.ilosc} x ${p.cena.toFixed(2)} PLN</div>
|
<div style="font-size:12px; color:var(--text-muted);">${escapeHtml(p.ilosc)} x ${Number(p.cena).toFixed(2)} PLN</div>
|
||||||
</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);
|
content.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -220,12 +219,6 @@ export async function selectDocument(docType) {
|
|||||||
}
|
}
|
||||||
trackEvent("bill_request_sent", { docType: "paragon" });
|
trackEvent("bill_request_sent", { docType: "paragon" });
|
||||||
closeBillDialog();
|
closeBillDialog();
|
||||||
sendApiSimulated("CallWaiter_Bill", {
|
|
||||||
table: getTableParam(),
|
|
||||||
billId: billState.selectedBillId,
|
|
||||||
payment: billState.payment,
|
|
||||||
doc: "paragon",
|
|
||||||
});
|
|
||||||
showToast(t("bill.toast_receipt"));
|
showToast(t("bill.toast_receipt"));
|
||||||
} else {
|
} else {
|
||||||
goToStep("stepNIP");
|
goToStep("stepNIP");
|
||||||
@@ -343,13 +336,5 @@ export async function confirmInvoice() {
|
|||||||
|
|
||||||
closeBillDialog();
|
closeBillDialog();
|
||||||
trackEvent("bill_request_sent", { docType: "faktura" });
|
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"));
|
showToast(t("bill.toast_invoice"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,4 @@ export const endpoints = {
|
|||||||
menu: cfg.endpoints?.menu || "../api/menu.php",
|
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;
|
export const loaderMinMs = Number(cfg.loaderMinMs) || 10_000;
|
||||||
|
|||||||
+195
-183
@@ -1,5 +1,5 @@
|
|||||||
import { endpoints, geoBypassHosts } from "./config.js";
|
import { endpoints, geoBypassHosts } from "./config.js";
|
||||||
import { t } from "./i18n.js";
|
import { onLangChange, t } from "./i18n.js";
|
||||||
import { trackEvent } from "./analytics.js";
|
import { trackEvent } from "./analytics.js";
|
||||||
import {
|
import {
|
||||||
runPendingProtectedAction,
|
runPendingProtectedAction,
|
||||||
@@ -20,95 +20,20 @@ import {
|
|||||||
setPendingProtectedAction,
|
setPendingProtectedAction,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|
||||||
// USER PROFILE LOGIC
|
/** @typedef {'consent'|'gate'|'blocked'|'checking'|'outside'|'https'|'unsupported'|'failed'} GeoUiMode */
|
||||||
const userProfileKey = "karczma_user_profile";
|
|
||||||
const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months
|
|
||||||
|
|
||||||
export function initUserProfile() {
|
const geoUi = {
|
||||||
return; // Funkcja tymczasowo wyłączona
|
/** @type {GeoUiMode} */
|
||||||
try {
|
mode: "consent",
|
||||||
const raw = localStorage.getItem(userProfileKey);
|
pendingAction: null,
|
||||||
let profile = null;
|
outsideDist: 0,
|
||||||
if (raw) {
|
outsideAccuracy: 0,
|
||||||
profile = JSON.parse(raw);
|
actionBusy: false,
|
||||||
}
|
/** @type {'locate'|'check'|'retry'|'checking'} */
|
||||||
|
actionLabel: "locate",
|
||||||
const now = Date.now();
|
/** @type {'menu_only'|'back_to_menu'} */
|
||||||
|
secondaryMode: "menu_only",
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function GEO_GATE_LABELS() {
|
function GEO_GATE_LABELS() {
|
||||||
return {
|
return {
|
||||||
@@ -118,8 +43,6 @@ function GEO_GATE_LABELS() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const GEO_DEFAULT_LEAD = () => t("geo.lead");
|
|
||||||
|
|
||||||
function setGeoLead(html) {
|
function setGeoLead(html) {
|
||||||
const el = document.getElementById("geoLead");
|
const el = document.getElementById("geoLead");
|
||||||
if (el) el.innerHTML = html;
|
if (el) el.innerHTML = html;
|
||||||
@@ -144,20 +67,6 @@ function hideGeoInstructions() {
|
|||||||
showGeoInstructions("");
|
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) {
|
function setGeoActionLabel(text) {
|
||||||
const btn = document.getElementById("geoActionBtn");
|
const btn = document.getElementById("geoActionBtn");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -166,14 +75,145 @@ function setGeoActionLabel(text) {
|
|||||||
else btn.textContent = text;
|
else btn.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGeoPermissionInstructions() {
|
function applyGeoActionLabel() {
|
||||||
return t("geo.hint.permission");
|
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() {
|
export function handleGeoMenuClick() {
|
||||||
if (geoMenuButtonMode === "back_to_menu") {
|
if (geoUi.secondaryMode === "back_to_menu") {
|
||||||
document.getElementById("geoScreen")?.classList.add("hidden");
|
document.getElementById("geoScreen")?.classList.add("hidden");
|
||||||
return;
|
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) {
|
export function showGeoGateForAction(action) {
|
||||||
const geoScreen = document.getElementById("geoScreen");
|
const geoScreen = document.getElementById("geoScreen");
|
||||||
const loadingScreen = document.getElementById("loadingScreen");
|
const loadingScreen = document.getElementById("loadingScreen");
|
||||||
const geoActionBtn = document.getElementById("geoActionBtn");
|
|
||||||
|
|
||||||
if (loadingScreen) loadingScreen.classList.add("hidden");
|
if (loadingScreen) loadingScreen.classList.add("hidden");
|
||||||
if (geoScreen) geoScreen.classList.remove("hidden");
|
if (geoScreen) geoScreen.classList.remove("hidden");
|
||||||
|
|
||||||
const feature = GEO_GATE_LABELS()[action] || t("geo.feature.other");
|
geoUi.mode = "gate";
|
||||||
setGeoLead(t("geo.lead_action", { feature }));
|
geoUi.pendingAction = action;
|
||||||
|
geoUi.actionBusy = false;
|
||||||
|
geoUi.actionLabel = "check";
|
||||||
|
geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
|
||||||
setGeoStatus("");
|
setGeoStatus("");
|
||||||
hideGeoInstructions();
|
hideGeoInstructions();
|
||||||
if (geoActionBtn) {
|
setGeoActionBusy(false);
|
||||||
setGeoActionBusy(false);
|
refreshGeoCopy();
|
||||||
setGeoActionLabel(t("geo.btn.check"));
|
|
||||||
}
|
|
||||||
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setGeoGateHandler(showGeoGateForAction);
|
setGeoGateHandler(showGeoGateForAction);
|
||||||
@@ -400,31 +413,33 @@ export function startApp() {
|
|||||||
}, 25000);
|
}, 25000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGeoPermissionDenied(error) {
|
||||||
|
return Number(error?.code) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
function showGeoConsentScreen() {
|
function showGeoConsentScreen() {
|
||||||
const geoScreen = document.getElementById("geoScreen");
|
const geoScreen = document.getElementById("geoScreen");
|
||||||
const loadingScreen = document.getElementById("loadingScreen");
|
const loadingScreen = document.getElementById("loadingScreen");
|
||||||
const geoActionBtn = document.getElementById("geoActionBtn");
|
|
||||||
|
|
||||||
loadingScreen.classList.add("hidden");
|
loadingScreen.classList.add("hidden");
|
||||||
geoScreen.classList.remove("hidden");
|
geoScreen.classList.remove("hidden");
|
||||||
|
|
||||||
setGeoLead(GEO_DEFAULT_LEAD());
|
geoUi.mode = "consent";
|
||||||
|
geoUi.pendingAction = null;
|
||||||
|
geoUi.actionLabel = "locate";
|
||||||
|
geoUi.secondaryMode = "menu_only";
|
||||||
setGeoStatus("");
|
setGeoStatus("");
|
||||||
hideGeoInstructions();
|
hideGeoInstructions();
|
||||||
|
setGeoActionBusy(false);
|
||||||
if (geoActionBtn) {
|
refreshGeoCopy();
|
||||||
setGeoActionBusy(false);
|
|
||||||
setGeoActionLabel(t("geo.btn.locate"));
|
|
||||||
}
|
|
||||||
configureGeoSecondaryButton("menu_only");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGeoPermissionBlockedState() {
|
function showGeoPermissionBlockedState() {
|
||||||
setGeoStatus(t("geo.status.blocked"), { error: true });
|
geoUi.mode = "blocked";
|
||||||
showGeoInstructions(getGeoPermissionInstructions());
|
geoUi.actionLabel = "retry";
|
||||||
|
geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
|
||||||
setGeoActionBusy(false);
|
setGeoActionBusy(false);
|
||||||
setGeoActionLabel(t("geo.btn.retry"));
|
refreshGeoCopy();
|
||||||
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldBypassGeolocationHost() {
|
export function shouldBypassGeolocationHost() {
|
||||||
@@ -517,29 +532,27 @@ export async function initGeolocationAfterBypassChecks(options = {}) {
|
|||||||
|
|
||||||
loadingScreen?.classList.add("hidden");
|
loadingScreen?.classList.add("hidden");
|
||||||
geoScreen?.classList.remove("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) {
|
if (!window.isSecureContext) {
|
||||||
setGeoLead(GEO_DEFAULT_LEAD());
|
geoUi.mode = "https";
|
||||||
setGeoStatus(t("geo.status.https"), { error: true });
|
geoUi.actionLabel = "retry";
|
||||||
showGeoInstructions(t("geo.hint.https"));
|
|
||||||
setGeoActionBusy(false);
|
setGeoActionBusy(false);
|
||||||
setGeoActionLabel(t("geo.btn.retry"));
|
refreshGeoCopy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setGeoLead(GEO_DEFAULT_LEAD());
|
geoUi.mode = "unsupported";
|
||||||
setGeoStatus(t("geo.status.unsupported"), { error: true });
|
geoUi.actionLabel = "retry";
|
||||||
hideGeoInstructions();
|
|
||||||
setGeoActionBusy(false);
|
setGeoActionBusy(false);
|
||||||
|
refreshGeoCopy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGeoLead(GEO_DEFAULT_LEAD());
|
geoUi.mode = "checking";
|
||||||
setGeoStatus(t("geo.status.checking"), { info: true });
|
|
||||||
hideGeoInstructions();
|
|
||||||
setGeoActionBusy(true);
|
setGeoActionBusy(true);
|
||||||
|
refreshGeoCopy();
|
||||||
|
|
||||||
const permissionState = await queryGeolocationPermissionState();
|
const permissionState = await queryGeolocationPermissionState();
|
||||||
if (permissionState === "denied") {
|
if (permissionState === "denied") {
|
||||||
@@ -573,36 +586,35 @@ export async function initGeolocationAfterBypassChecks(options = {}) {
|
|||||||
distanceMeters: Math.round(dist),
|
distanceMeters: Math.round(dist),
|
||||||
accuracyMeters: Math.round(accuracy),
|
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);
|
setGeoActionBusy(false);
|
||||||
setGeoActionLabel(t("geo.btn.retry"));
|
refreshGeoCopy();
|
||||||
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"));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackEvent("geo_check_failed", {
|
trackEvent("geo_check_failed", {
|
||||||
reason: "browser_error",
|
reason: "browser_error",
|
||||||
code: error.code || null,
|
code: error.code || null,
|
||||||
message: String(error.message || ""),
|
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 || ""));
|
const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || ""));
|
||||||
|
|
||||||
if (deniedBecauseInsecure) {
|
if (deniedBecauseInsecure) {
|
||||||
setGeoStatus(t("geo.status.https_short"), { error: true });
|
geoUi.mode = "https";
|
||||||
showGeoInstructions(t("geo.hint.https"));
|
geoUi.actionLabel = "retry";
|
||||||
|
geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
|
||||||
|
setGeoActionBusy(false);
|
||||||
|
refreshGeoCopy();
|
||||||
} else if (isGeoPermissionDenied(error)) {
|
} else if (isGeoPermissionDenied(error)) {
|
||||||
showGeoPermissionBlockedState();
|
showGeoPermissionBlockedState();
|
||||||
} else {
|
} else {
|
||||||
setGeoStatus(t("geo.status.failed"), { error: true });
|
geoUi.mode = "failed";
|
||||||
showGeoInstructions(t("geo.hint.retry"));
|
geoUi.actionLabel = "retry";
|
||||||
|
geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only";
|
||||||
|
setGeoActionBusy(false);
|
||||||
|
refreshGeoCopy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import de from "../locales/de.js";
|
|||||||
|
|
||||||
const STORAGE_KEY = "karczma_lang";
|
const STORAGE_KEY = "karczma_lang";
|
||||||
const catalogs = { pl, en, de };
|
const catalogs = { pl, en, de };
|
||||||
|
const LANG_BUSY_MIN_MS = 280;
|
||||||
|
|
||||||
let currentLang = "pl";
|
let currentLang = "pl";
|
||||||
/** @type {Set<(lang: string) => void>} */
|
let langBusy = false;
|
||||||
|
/** @type {Set<(lang: string) => void | Promise<void>>} */
|
||||||
const listeners = new Set();
|
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 = {}) {
|
function interpolate(template, vars = {}) {
|
||||||
return String(template).replace(/\{(\w+)\}/g, (_, key) => {
|
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() {
|
export function getAvailableLanguages() {
|
||||||
const fromConfig = window.APP_CONFIG?.languages;
|
const fromConfig = window.APP_CONFIG?.languages;
|
||||||
if (Array.isArray(fromConfig) && fromConfig.length) {
|
if (Array.isArray(fromConfig) && fromConfig.length) {
|
||||||
@@ -31,6 +63,10 @@ export function getLang() {
|
|||||||
return currentLang;
|
return currentLang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLangBusy() {
|
||||||
|
return langBusy;
|
||||||
|
}
|
||||||
|
|
||||||
export function t(key, vars = {}) {
|
export function t(key, vars = {}) {
|
||||||
const catalog = catalogs[currentLang] || catalogs.pl;
|
const catalog = catalogs[currentLang] || catalogs.pl;
|
||||||
const fallback = catalogs.pl;
|
const fallback = catalogs.pl;
|
||||||
@@ -72,7 +108,12 @@ export function onLangChange(callback) {
|
|||||||
return () => listeners.delete(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 next = catalogs[lang] ? lang : "pl";
|
||||||
const changed = next !== currentLang;
|
const changed = next !== currentLang;
|
||||||
currentLang = next;
|
currentLang = next;
|
||||||
@@ -87,14 +128,38 @@ export function setLang(lang, { persist = true, notify = true } = {}) {
|
|||||||
|
|
||||||
applyTranslations();
|
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) => {
|
listeners.forEach((cb) => {
|
||||||
try {
|
try {
|
||||||
cb(currentLang);
|
const result = cb(currentLang);
|
||||||
|
if (result != null && typeof result.then === "function") {
|
||||||
|
tasks.push(result);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[i18n] listener failed", 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;
|
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">`;
|
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" } = {}) {
|
export function createLanguagePicker({ idPrefix = "lang" } = {}) {
|
||||||
|
ensureDocumentClickHandler();
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "lang-picker";
|
wrap.className = "lang-picker";
|
||||||
wrap.dataset.langPicker = "1";
|
wrap.dataset.langPicker = "1";
|
||||||
@@ -133,13 +204,41 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
|
|||||||
menu.id = `${idPrefix}Menu`;
|
menu.id = `${idPrefix}Menu`;
|
||||||
menu.setAttribute("role", "listbox");
|
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 langs = getAvailableLanguages();
|
||||||
const current = langs.find((l) => l.code === getLang()) || langs[0];
|
const current = langs.find((l) => l.code === getLang()) || langs[0];
|
||||||
const code = current?.code || "pl";
|
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.innerHTML = flagImgHtml(code);
|
||||||
btn.setAttribute("aria-label", `${t("lang.aria")}: ${current?.label || code}`);
|
btn.setAttribute("aria-label", `${t("lang.aria")}: ${current?.label || code}`);
|
||||||
|
wrap.classList.remove("is-loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
paintButton();
|
||||||
|
|
||||||
|
const langs = getAvailableLanguages();
|
||||||
menu.innerHTML = "";
|
menu.innerHTML = "";
|
||||||
langs.forEach((lang) => {
|
langs.forEach((lang) => {
|
||||||
const option = document.createElement("button");
|
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.className = "lang-picker-option" + (lang.code === getLang() ? " is-active" : "");
|
||||||
option.setAttribute("role", "option");
|
option.setAttribute("role", "option");
|
||||||
option.setAttribute("aria-selected", lang.code === getLang() ? "true" : "false");
|
option.setAttribute("aria-selected", lang.code === getLang() ? "true" : "false");
|
||||||
|
option.disabled = langBusy;
|
||||||
option.innerHTML = `${flagImgHtml(lang.code)}<span>${lang.label}</span>`;
|
option.innerHTML = `${flagImgHtml(lang.code)}<span>${lang.label}</span>`;
|
||||||
option.addEventListener("click", (e) => {
|
option.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
close();
|
if (langBusy) return;
|
||||||
|
pickerApi.close();
|
||||||
if (lang.code !== getLang()) {
|
if (lang.code !== getLang()) {
|
||||||
setLang(lang.code);
|
void setLang(lang.code);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
menu.appendChild(option);
|
menu.appendChild(option);
|
||||||
@@ -160,25 +261,21 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
if (langBusy) return;
|
||||||
|
openPickerClosers.forEach((p) => {
|
||||||
|
if (p !== pickerApi) p.close();
|
||||||
|
});
|
||||||
menu.classList.remove("hidden");
|
menu.classList.remove("hidden");
|
||||||
btn.setAttribute("aria-expanded", "true");
|
btn.setAttribute("aria-expanded", "true");
|
||||||
wrap.classList.add("is-open");
|
wrap.classList.add("is-open");
|
||||||
}
|
openPickerClosers.add(pickerApi);
|
||||||
|
|
||||||
function close() {
|
|
||||||
menu.classList.add("hidden");
|
|
||||||
btn.setAttribute("aria-expanded", "false");
|
|
||||||
wrap.classList.remove("is-open");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (langBusy) return;
|
||||||
if (menu.classList.contains("hidden")) open();
|
if (menu.classList.contains("hidden")) open();
|
||||||
else close();
|
else pickerApi.close();
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (!wrap.contains(e.target)) close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(btn);
|
wrap.appendChild(btn);
|
||||||
@@ -186,6 +283,10 @@ export function createLanguagePicker({ idPrefix = "lang" } = {}) {
|
|||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
onLangChange(() => refresh());
|
onLangChange(() => refresh());
|
||||||
|
onLangBusyChange(() => {
|
||||||
|
if (langBusy) pickerApi.close();
|
||||||
|
paintButton();
|
||||||
|
});
|
||||||
|
|
||||||
return { el: wrap, refresh, close };
|
return { el: wrap, refresh, close: pickerApi.close };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ let itemModalIndex = -1;
|
|||||||
let itemModalTouchStart = null;
|
let itemModalTouchStart = null;
|
||||||
let itemModalDragging = false;
|
let itemModalDragging = false;
|
||||||
let itemModalAnimating = false;
|
let itemModalAnimating = false;
|
||||||
let menuLangChangeBound = false;
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value).replace(/`/g, "`");
|
||||||
|
}
|
||||||
|
|
||||||
function resetItemModalPane() {
|
function resetItemModalPane() {
|
||||||
const pane = document.getElementById("itemModalPane");
|
const pane = document.getElementById("itemModalPane");
|
||||||
@@ -77,10 +89,10 @@ function renderMenuListImage(url) {
|
|||||||
const hasUrl = isValidMenuImageUrl(url);
|
const hasUrl = isValidMenuImageUrl(url);
|
||||||
const imgClass = hasUrl ? "rmc-image" : "rmc-image hidden";
|
const imgClass = hasUrl ? "rmc-image" : "rmc-image hidden";
|
||||||
const placeholderClass = hasUrl ? "menu-image-placeholder hidden" : "menu-image-placeholder";
|
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)"' : "";
|
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) {
|
function findMenuItem(categoryId, position) {
|
||||||
@@ -250,13 +262,13 @@ function renderCategoryNav(categories) {
|
|||||||
if (!ul) return;
|
if (!ul) return;
|
||||||
|
|
||||||
const list = Array.isArray(categories) ? categories : [];
|
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) => {
|
list.forEach((cat) => {
|
||||||
const id = String(cat?.id ?? "").trim();
|
const id = String(cat?.id ?? "").trim();
|
||||||
const name = String(cat?.name ?? "").trim();
|
const name = String(cat?.name ?? "").trim();
|
||||||
if (!id || !name) return;
|
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;
|
ul.innerHTML = html;
|
||||||
@@ -264,11 +276,19 @@ function renderCategoryNav(categories) {
|
|||||||
|
|
||||||
function showMenuLoadError(container) {
|
function showMenuLoadError(container) {
|
||||||
if (!container) return;
|
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()) {
|
export async function loadMenu(lang = getLang()) {
|
||||||
const container = document.getElementById("menuContainer");
|
const container = document.getElementById("menuContainer");
|
||||||
|
const previousCategoryId = getActiveCategoryId();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${endpoints.menu}?lang=${encodeURIComponent(lang)}`);
|
const response = await fetch(`${endpoints.menu}?lang=${encodeURIComponent(lang)}`);
|
||||||
if (!response.ok) throw new Error(t("menu.load_error"));
|
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.className = "rm-category";
|
||||||
catDiv.setAttribute("data-cat-id", catId);
|
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">`;
|
<div class="rmc-positions">`;
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
|
const categoryId = escapeAttr(item.categoryId);
|
||||||
|
const position = escapeAttr(item.position);
|
||||||
html += `
|
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)}
|
${renderMenuListImage(item.image)}
|
||||||
<div class="rmc-title">
|
<div class="rmc-title">
|
||||||
<h4>${item.title}<span>${item.description || ""}</span></h4>
|
<h4>${escapeHtml(item.title)}<span>${escapeHtml(item.description || "")}</span></h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="rmc-other"><span>${item.price}</span></div>
|
<div class="rmc-other"><span>${escapeHtml(item.price)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -313,7 +335,12 @@ export async function loadMenu(lang = getLang()) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
renderCategoryNav(payload.categories);
|
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");
|
const searchInput = document.getElementById("menuSearchInput");
|
||||||
if (searchInput?.value) {
|
if (searchInput?.value) {
|
||||||
@@ -325,13 +352,7 @@ export async function loadMenu(lang = getLang()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setMenuLanguageReload() {
|
onLangChange((lang) => loadMenu(lang));
|
||||||
if (menuLangChangeBound) return;
|
|
||||||
menuLangChangeBound = true;
|
|
||||||
onLangChange(() => loadMenu());
|
|
||||||
}
|
|
||||||
|
|
||||||
setMenuLanguageReload();
|
|
||||||
|
|
||||||
export function openItemModal(categoryId, position) {
|
export function openItemModal(categoryId, position) {
|
||||||
itemModalKeys = buildVisibleMenuItemKeys();
|
itemModalKeys = buildVisibleMenuItemKeys();
|
||||||
@@ -370,7 +391,8 @@ export function closeItemModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterMenu() {
|
export function filterMenu() {
|
||||||
const query = document.getElementById("menuSearchInput").value.toLowerCase();
|
const searchInput = document.getElementById("menuSearchInput");
|
||||||
|
const query = (searchInput?.value || "").toLowerCase();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) {
|
if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) {
|
||||||
window.lastMenuSearchEventAt = now;
|
window.lastMenuSearchEventAt = now;
|
||||||
@@ -383,7 +405,7 @@ export function filterMenu() {
|
|||||||
const items = category.querySelectorAll(".rmc-position");
|
const items = category.querySelectorAll(".rmc-position");
|
||||||
|
|
||||||
items.forEach((item) => {
|
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)) {
|
if (title.includes(query)) {
|
||||||
item.style.display = "";
|
item.style.display = "";
|
||||||
hasVisibleItems = true;
|
hasVisibleItems = true;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { endpoints, loaderMinMs } from "./config.js";
|
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 { runPendingProtectedAction, updateNavAccessState } from "./access.js";
|
||||||
import {
|
import {
|
||||||
refreshGuestPendingActions,
|
refreshGuestPendingActions,
|
||||||
@@ -31,6 +31,19 @@ const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
|
|||||||
const LOADER_MIN_MS = loaderMinMs;
|
const LOADER_MIN_MS = loaderMinMs;
|
||||||
const loadStartTime = Date.now();
|
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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
function getLoaderMsgs() {
|
function getLoaderMsgs() {
|
||||||
return [t("loader.msg1"), t("loader.msg2"), t("loader.msg3"), t("loader.msg4")];
|
return [t("loader.msg1"), t("loader.msg2"), t("loader.msg3"), t("loader.msg4")];
|
||||||
}
|
}
|
||||||
@@ -56,6 +69,20 @@ if (getTableParam()) {
|
|||||||
tableLabel.textContent = formatTableLabel(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. */
|
/** Storage key must follow current hash (fallback: table), not a frozen empty tableParam at init. */
|
||||||
export function getOrderStorageKey() {
|
export function getOrderStorageKey() {
|
||||||
const key = (getHashParam() || getTableParam() || "unknown").toLowerCase();
|
const key = (getHashParam() || getTableParam() || "unknown").toLowerCase();
|
||||||
@@ -70,7 +97,7 @@ export function hideLoader() {
|
|||||||
clearInterval(msgInterval);
|
clearInterval(msgInterval);
|
||||||
const bottomNav = document.getElementById("bottomNav");
|
const bottomNav = document.getElementById("bottomNav");
|
||||||
if (bottomNav) {
|
if (bottomNav) {
|
||||||
bottomNav.style.display = "";
|
bottomNav.hidden = false;
|
||||||
}
|
}
|
||||||
updateNavAccessState();
|
updateNavAccessState();
|
||||||
if (getPendingProtectedAction() && getAppAccessLevel() === "full") {
|
if (getPendingProtectedAction() && getAppAccessLevel() === "full") {
|
||||||
@@ -94,11 +121,15 @@ export async function resolveTableLabel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUI(bills) {
|
export function updateUI(bills, { manageLoader = true } = {}) {
|
||||||
// Hide loader after minimum display time
|
lastBillsForUi = Array.isArray(bills) ? bills : [];
|
||||||
hideLoader();
|
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);
|
const items = mergeWithPersistedItems(allArticles);
|
||||||
renderGlobalHistory();
|
renderGlobalHistory();
|
||||||
|
|
||||||
@@ -108,7 +139,7 @@ export function updateUI(bills) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderItems(items);
|
renderItems(items);
|
||||||
updateStatus(bills, items);
|
updateStatus(lastBillsForUi, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEmptyState() {
|
function showEmptyState() {
|
||||||
@@ -235,14 +266,14 @@ export function renderGlobalHistory() {
|
|||||||
div.className = "item-card archived ready";
|
div.className = "item-card archived ready";
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="item-name">${entry.name}</span>
|
<span class="item-name">${escapeHtml(entry.name)}</span>
|
||||||
<span class="item-meta">${t("history.entry_meta", {
|
<span class="item-meta">${escapeHtml(t("history.entry_meta", {
|
||||||
table: formatTableLabel(entry.sourceTable || "?"),
|
table: formatTableLabel(entry.sourceTable || "?"),
|
||||||
date: dt.toLocaleDateString(lang),
|
date: dt.toLocaleDateString(lang),
|
||||||
time: dt.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }),
|
time: dt.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }),
|
||||||
})}</span>
|
}))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-qty">x${entry.qty}</div>
|
<div class="item-qty">x${escapeHtml(entry.qty)}</div>
|
||||||
`;
|
`;
|
||||||
historyList.appendChild(div);
|
historyList.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -326,10 +357,10 @@ function renderItems(items) {
|
|||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="item-name">${item.name}</span>
|
<span class="item-name">${escapeHtml(item.name)}</span>
|
||||||
<span class="item-meta">${meta}</span>
|
<span class="item-meta">${escapeHtml(meta)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-qty">x${item.qty}</div>
|
<div class="item-qty">x${escapeHtml(item.qty)}</div>
|
||||||
`;
|
`;
|
||||||
itemsList.appendChild(div);
|
itemsList.appendChild(div);
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-1
@@ -26,13 +26,26 @@ function requireAdminAuth(bool $redirectToLogin = true): void
|
|||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
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
|
function attemptAdminLogin(string $username, string $password): bool
|
||||||
{
|
{
|
||||||
startAdminSession();
|
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_logged_in'] = true;
|
||||||
$_SESSION['staff_username'] = ADMIN_USERNAME;
|
$_SESSION['staff_username'] = ADMIN_USERNAME;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const API_URL = '../../api/waiter_feed.php';
|
const API_URL = '../../api/waiter_feed.php';
|
||||||
const POLL_MS = 15000;
|
const POLL_MS = 15000;
|
||||||
|
const FEED_TOKEN = String(window.WAITER_CONFIG?.feedToken || '');
|
||||||
|
|
||||||
const feedList = document.getElementById('feedList');
|
const feedList = document.getElementById('feedList');
|
||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
@@ -238,7 +239,10 @@ function setSyncState(ok, message) {
|
|||||||
|
|
||||||
async function pollFeed() {
|
async function pollFeed() {
|
||||||
try {
|
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();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.status !== 'success') {
|
if (result.status !== 'success') {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ require_once __DIR__ . '/../includes/asset_version.php';
|
|||||||
$waiterDir = __DIR__;
|
$waiterDir = __DIR__;
|
||||||
$vCss = publicAssetVersion($waiterDir, 'app.css');
|
$vCss = publicAssetVersion($waiterDir, 'app.css');
|
||||||
$vJs = publicAssetVersion($waiterDir, 'app.js');
|
$vJs = publicAssetVersion($waiterDir, 'app.js');
|
||||||
|
$waiterConfig = require __DIR__ . '/../../config/waiter.php';
|
||||||
|
$waiterFeedToken = (string) ($waiterConfig['feed_token'] ?? '');
|
||||||
?><!DOCTYPE html>
|
?><!DOCTYPE html>
|
||||||
<html lang="pl">
|
<html lang="pl">
|
||||||
<head>
|
<head>
|
||||||
@@ -19,6 +21,9 @@ $vJs = publicAssetVersion($waiterDir, 'app.js');
|
|||||||
<link rel="apple-touch-icon" href="icons/icon-192.png">
|
<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 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) ?>">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
|
|||||||
Reference in New Issue
Block a user