Naprawa lokalizacji - odczytu

This commit is contained in:
2026-06-10 20:31:48 +02:00
parent 04aaa6e321
commit 79a83d4d73
16 changed files with 1826 additions and 477 deletions

5
.htaccess Normal file
View File

@@ -0,0 +1,5 @@
# Gdy ten plik leży w public_html/biesiada.menu/ (nad katalogiem app/)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^app/public/app\.html$ app/public/app.php [L,QSA]
</IfModule>

22
api/geo_bypass.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/request_ip.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
$clientIp = getRequestClientIp();
$bypass = isGeoBypassTrustedIp($clientIp);
echo json_encode([
'status' => 'success',
'bypassGeo' => $bypass,
'clientIp' => $clientIp,
], JSON_UNESCAPED_UNICODE);

94
api/request_ip.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
/**
* Adres IP klienta (pierwszy z X-Forwarded-For lub REMOTE_ADDR).
*/
function getRequestClientIp(): string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
}
/**
* Pojedyncze IP z pominięciem geo (zewnętrzne, dev, przykładowe hosty LAN).
*/
function getGeoBypassTrustedIps(): array
{
return [
'82.160.190.247',
'127.0.0.1',
'::1',
'192.168.20.84',
'10.0.0.3',
'10.0.0.7',
];
}
/**
* Pule wewnętrznych sieci — goście widziani przez lokalny serwer (REMOTE_ADDR z LAN).
*/
function getGeoBypassTrustedCidrs(): array
{
return [
'10.0.0.0/24',
];
}
function normalizeClientIp(string $ip): string
{
if (strpos($ip, '::ffff:') === 0) {
return substr($ip, 7);
}
return $ip;
}
function ipv4InCidr(string $ip, string $cidr): bool
{
if (!str_contains($cidr, '/')) {
return false;
}
[$subnet, $bits] = explode('/', $cidr, 2);
$bits = (int) $bits;
if ($bits < 0 || $bits > 32) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
return ($ipLong & $mask) === ($subnetLong & $mask);
}
function isGeoBypassTrustedIp(string $ip): bool
{
$ip = normalizeClientIp($ip);
if ($ip === '') {
return false;
}
if (in_array($ip, getGeoBypassTrustedIps(), true)) {
return true;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
foreach (getGeoBypassTrustedCidrs() as $cidr) {
if (ipv4InCidr($ip, $cidr)) {
return true;
}
}
}
return false;
}

77
api/waiter_feed.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/message_text_helper.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = getAnalyticsPdo();
$stmt = $pdo->query("
SELECT
id,
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
status_kds,
api_sent,
created_at,
updated_at
FROM guest_action_queue
WHERE DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 500
");
$rows = $stmt->fetchAll();
$pending = 0;
$waiterCalls = 0;
$billRequests = 0;
foreach ($rows as &$row) {
$row['id'] = (int) $row['id'];
$row['status_kds'] = (int) $row['status_kds'];
$row['api_sent'] = (int) $row['api_sent'];
$row['message_text'] = normalizeQueueMessageText((string) ($row['message_text'] ?? ''));
if ($row['status_kds'] === 0) {
$pending++;
}
if ($row['message_type'] === 'waiter_call') {
$waiterCalls++;
} elseif ($row['message_type'] === 'bill_request') {
$billRequests++;
}
}
unset($row);
echo json_encode([
'status' => 'success',
'date' => date('Y-m-d'),
'count' => count($rows),
'summary' => [
'pending' => $pending,
'waiter_calls' => $waiterCalls,
'bill_requests' => $billRequests,
],
'polled_at' => date('Y-m-d H:i:s'),
'poll_interval_seconds' => 15,
'data' => $rows,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Waiter feed fetch failed',
], JSON_UNESCAPED_UNICODE);
}

5
app/.htaccess Normal file
View File

@@ -0,0 +1,5 @@
# Gdy document root wskazuje na katalog app/ (URL: /public/app.html)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^public/app\.html$ public/app.php [L,QSA]
</IfModule>

View File

@@ -74,6 +74,9 @@
<a href="public/staff/kds.php" class="dev-link kds">
🍳 Ekran KDS (Kuchnia)
</a>
<a href="public/waiter/index.php" class="dev-link client">
🛎️ Panel Kelnera (wezwania)
</a>
<a href="public/app.html" class="dev-link client">
📱 Aplikacja dla Gościa (Poprosi o Hash)
</a>

17
public/.htaccess Normal file
View File

@@ -0,0 +1,17 @@
# Aplikacja gościa: świeży HTML/PHP (w app.php dynamiczne ?v= z filemtime)
<IfModule mod_headers.c>
<FilesMatch "^app\.(html|php)$">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
# Nie serwuj app.html zamiast reguły rewrite / app.php
Options -MultiViews
# QR: app.html → app.php (gdy mod_rewrite działa; inaczej app.html robi redirect w JS)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^app\.html$ app.php [L,QSA]
</IfModule>

View File

@@ -1,340 +1,24 @@
<!doctype html>
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Karczma Biesiada Twoje Zamówienie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="assets/css/app.css">
</head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Karczma Biesiada przekierowanie</title>
<script>
(function () {
var target = "app.php" + window.location.search + window.location.hash;
window.location.replace(target);
})();
</script>
</head>
<body>
<div id="loadingScreen">
<div class="loader-icon"></div>
<div class="loader-text">
<h2>Karczma Biesiada</h2>
<div class="loader-msg" id="loaderMsg">Łączenie z kuchnią...</div>
</div>
</div>
<div id="geoScreen" class="hidden">
<div class="geo-icon">📍</div>
<div class="geo-text">
<h2>Prywatność i Lokalizacja</h2>
<div class="geo-msg" id="geoMsg">
Aby zapewnić bezpieczeństwo Twojego zamówienia, musimy upewnić się, że znajdujesz się na terenie
restauracji.<br><br>Prosimy o udzielenie zgody na dostęp do lokalizacji w przeglądarce.
</div>
<button id="geoActionBtn" class="btn btn-primary" style="margin-top: 24px; max-width: 250px; margin-left: auto; margin-right: auto;"
onclick="initGeolocation()">Udziel zgody / Sprawdź</button>
<p class="geo-hint" style="margin-top: 20px; font-size: 13px; color: var(--text-muted); line-height: 1.5; max-width: 320px; margin-left: auto; margin-right: auto;">
Status zamówienia, kelner i rachunek wymagają potwierdzenia, że jesteś w restauracji.
</p>
<button id="geoMenuOnlyBtn" class="btn btn-secondary" style="margin-top: 12px; max-width: 250px; margin-left: auto; margin-right: auto;">Przeglądaj menu bez lokalizacji</button>
</div>
</div>
<div class="container">
<header id="mainHeader">
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="tableLabel" class="table-badge">Wybierz stolik</div>
</header>
<!-- <div id="greetingBanner"
style="display:none; text-align:center; padding: 10px; font-weight:600; color:var(--primary); font-family:'Playfair Display', serif; font-size:18px;">
</div> -->
<main id="mainContent">
<div id="statusView" class="view-section active">
<section class="status-card">
<div class="status-header">
<div>
<span class="status-title">Aktualny status</span>
<div id="prepStatus" class="status-value">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);">
Sprawdzamy co pysznego się przygotowuje...
</div>
</section>
<section class="items-container" id="ordersContainer">
<h3 id="ordersTitle">Twoje zamówione dania</h3>
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon">📖</div>
<p style="color: var(--text-muted)">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')">Przeglądaj menu</button>
</div>
<div id="itemsList"></div>
</section>
<section id="historySection" class="items-container history-section hidden">
<h3>Twoje poprzednie zamówienia</h3>
<p class="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;">Usuń historię</a>
</div>
</section>
<div id="metaFooter" class="meta-footer"></div>
</div> <!-- Koniec statusView -->
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner hidden">
<span>Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()">Sprawdź teraz</button>
</div>
<div class="menu-search-container">
<input type="text" id="menuSearchInput" placeholder="Szukaj dania..." oninput="filterMenu()" />
</div>
<div class="restaurant-menu-container">
<nav class="menu-categories-nav">
<ul>
<li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0)">Wszystko</a></li>
<li><a href="#" onclick="showCategory(1)" data-category-badge="1">Przystawki</a></li>
<li><a href="#" onclick="showCategory(2)" data-category-badge="2">Zupy</a></li>
<li><a href="#" onclick="showCategory(3)" data-category-badge="3">Dania główne</a></li>
<li><a href="#" onclick="showCategory(4)" data-category-badge="4">Dania swojskie</a></li>
<li><a href="#" onclick="showCategory(5)" data-category-badge="5">Ryby</a></li>
<li><a href="#" onclick="showCategory(7)" data-category-badge="7">Sałatki</a></li>
<li><a href="#" onclick="showCategory(6)" data-category-badge="6">Makarony</a></li>
<li><a href="#" onclick="showCategory(9)" data-category-badge="9">Dla dzieci</a></li>
<li><a href="#" onclick="showCategory(8)" data-category-badge="8">Dodatki</a></li>
<li><a href="#" onclick="showCategory(10)" data-category-badge="10">Desery</a></li>
<li><a href="#" onclick="showCategory(11)" data-category-badge="11">Napoje</a></li>
</ul>
</nav>
<div class="restaurant-menu-scroll" id="menuContainer">
<!-- Dynamiczne menu załaduje się tutaj -->
</div>
</div>
</div> <!-- Koniec menuView -->
</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>
<!-- Bottom Navigation Bar -->
<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">Zamówienie</span>
</div>
<div class="nav-item" onclick="switchTab('menu')" id="navMenu">
<span class="nav-icon">📖</span>
<span class="nav-label">Menu</span>
</div>
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
<span class="nav-icon">🛎️</span>
<span class="nav-label">Kelner</span>
</div>
<div class="nav-item action-bill" onclick="openBillDialog()" id="navBill">
<span class="nav-icon">💳</span>
<span class="nav-label">Rachunek</span>
</div>
</nav>
<!-- WAITER DIALOG -->
<div class="modal-overlay" id="waiterModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">🛎️</div>
<h3 style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px;">
Przywołać obsługę?</h3>
<p style="color: var(--text-muted); font-size: 15px; margin-bottom: 25px; line-height: 1.5;">
Kelner otrzyma natychmiastowe powiadomienie na swoim panelu i podejdzie do Twojego stolika najszybciej jak to
możliwe.
</p>
<div style="display: flex; gap: 12px; flex-direction: column;">
<button class="btn btn-primary" onclick="confirmCallWaiter()" style="padding: 14px; font-size: 16px;">Tak,
poproś kelnera</button>
<button class="btn btn-secondary" onclick="closeWaiterDialog()" style="padding: 14px;">Anuluj</button>
</div>
</div>
</div>
<!-- NAME DIALOG (Tymczasowo wyłączone)
<div class="modal-overlay" id="nameModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">👋</div>
<h3 style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px;">Podaj
swoje imię</h3>
<p style="color: var(--text-muted); font-size: 15px; margin-bottom: 20px; line-height: 1.5;">
Dzięki temu będziemy mogli powitać Cię osobiście podczas Twojej wizyty!
</p>
<div class="input-group" style="margin-bottom: 25px; text-align: left;">
<input type="text" id="userNameInput" class="input-field" placeholder="Twoje imię..." autocomplete="off" />
</div>
<div style="display: flex; gap: 12px; flex-direction: column;">
<button class="btn btn-primary" onclick="saveUserName()" style="padding: 14px; font-size: 16px;">Idę
dalej</button>
<button class="btn btn-secondary" onclick="declineUserName()" style="padding: 14px;">Nie chcę podawać</button>
</div>
</div>
</div>
-->
<!-- ITEM MODAL -->
<div class="modal-overlay" id="itemModal">
<div class="modal-content" style="padding: 0; overflow: hidden; max-width: 380px;">
<div style="position: relative;">
<img id="itemModalImage" src="" style="width: 100%; height: 240px; object-fit: cover; display: block;" alt="">
<button class="close-btn" onclick="closeItemModal()" style="position: absolute; top: 10px; right: 15px; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,0.8); font-size: 32px; z-index: 10; background: rgba(0,0,0,0.3); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; line-height: 1;">&times;</button>
</div>
<div style="padding: 24px; text-align: left;">
<h3 id="itemModalTitle" style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px; margin-bottom: 8px;"></h3>
<div id="itemModalPrice" style="color: var(--primary); font-weight: 700; font-size: 20px; margin-bottom: 16px;"></div>
<p id="itemModalDesc" style="color: var(--text-muted); font-size: 15px; line-height: 1.6; margin-bottom: 24px;"></p>
<button class="btn btn-secondary" onclick="closeItemModal()" style="width: 100%;">Zamknij</button>
</div>
</div>
</div>
<!-- BILL DIALOG -->
<div class="modal-overlay" id="billModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Rozliczenie</h3>
<button class="close-btn" onclick="closeBillDialog()">&times;</button>
</div>
<!-- Step 0: Loading or List of Bills -->
<div class="step active" id="stepBillList">
<div id="billLoading" style="text-align:center; padding: 20px;">
⏳ Pobieranie rachunków...
</div>
<div id="billListContainer" class="hidden">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Mamy kilka otwartych
rachunków na tym stoliku. Który chcesz opłacić?</p>
<div id="billListItems" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
</div>
<!-- Step 0.5: Bill Review -->
<div class="step" id="stepBillReview">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Podsumowanie rachunku:
</p>
<div id="billReviewContent"
style="background: var(--surface-light); padding: 15px; border-radius: 12px; max-height: 40vh; overflow-y: auto; margin-bottom: 15px;">
</div>
<div style="display:flex; justify-content:space-between; font-weight:700; font-size:18px; margin-bottom: 20px;">
<span>Do zapłaty:</span>
<span id="billTotalAmount" style="color:var(--primary);">0.00 PLN</span>
</div>
<div style="display:flex; gap:12px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goBackToBillList()"
id="btnBackToBills">Wróć</button>
<button class="btn btn-primary" style="flex:2;" onclick="proceedToBillPayment()">Poproś rachunek</button>
</div>
</div>
<!-- Step 1: Payment Method -->
<div class="step" id="stepPayment">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Wybierz preferowaną formę
płatności:</p>
<div class="option-grid">
<div class="option-card" onclick="selectPayment('karta')">
<span class="option-icon">💳</span>
<span class="option-label">Karta</span>
</div>
<div class="option-card" onclick="selectPayment('gotówka')">
<span class="option-icon">💵</span>
<span class="option-label">Gotówka</span>
</div>
</div>
<button class="btn btn-secondary" onclick="goToStep('stepBillReview')" style="margin-top: 15px;">Wróć do
podsumowania</button>
</div>
<!-- Step 2: Document Type -->
<div class="step" id="stepDocument">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Jakiego dokumentu
potrzebujesz?</p>
<div class="option-grid">
<div class="option-card" onclick="selectDocument('paragon')">
<span class="option-icon">🧾</span>
<span class="option-label">Paragon</span>
</div>
<div class="option-card" onclick="selectDocument('faktura')">
<span class="option-icon">📄</span>
<span class="option-label">Faktura</span>
</div>
</div>
<button class="btn btn-secondary" onclick="goToStep('stepPayment')" style="margin-top: 8px;">Wróć</button>
</div>
<!-- Step 3: NIP Input -->
<div class="step" id="stepNIP">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Wprowadź NIP firmy,
abyśmy mogli automatycznie pobrać dane.</p>
<div class="input-group">
<label class="input-label">Numer NIP</label>
<input type="number" id="nipInput" class="input-field" placeholder="np. 1234567890" autocomplete="off" />
</div>
<div style="display:flex; gap:12px; margin-top: 24px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goToStep('stepDocument')">Wróć</button>
<button class="btn btn-primary" style="flex:2;" onclick="fetchGUS()" id="btnGUS">Pobierz z GUS</button>
</div>
</div>
<!-- Step 4: Verify Data -->
<div class="step" id="stepVerify">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Czy poniższe dane do
faktury są prawidłowe?</p>
<div class="company-details">
<input type="text" id="cmpName" class="company-input" style="font-weight:700; margin-bottom:4px;" readonly />
<input type="text" id="cmpStreet" class="company-input" placeholder="Ulica i numer" style="margin-bottom:4px;"
readonly />
<div style="display: flex; gap: 8px; margin-bottom: 4px;">
<input type="text" id="cmpZip" class="company-input" placeholder="Kod" style="flex: 1;" readonly />
<input type="text" id="cmpCity" class="company-input" placeholder="Miasto" style="flex: 2;" readonly />
</div>
<input type="text" id="cmpNip" class="company-input muted" readonly />
</div>
<div style="display:flex; gap:12px; flex-direction:column;">
<button class="btn btn-primary" onclick="confirmInvoice()">Tak, poproszę fakturę!</button>
<div style="display:flex; gap:12px;">
<button class="btn btn-secondary" onclick="goToStep('stepNIP')">Zmień NIP</button>
<button class="btn btn-secondary" onclick="editCompanyData()" id="btnEditCompany">Popraw ręcznie</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toastMsg">
<span style="font-size:20px;"></span> <span id="toastText">Wysłano!</span>
</div>
<script src="assets/js/app.js"></script>
<p style="font-family: sans-serif; text-align: center; margin-top: 2rem; color: #333;">
Przekierowanie do aplikacji…
<br><br>
<a href="app.php">Kliknij tutaj, jeśli nic się nie dzieje</a>
</p>
</body>
</html>
</html>

378
public/app.php Normal file
View File

@@ -0,0 +1,378 @@
<?php
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: 0');
require_once __DIR__ . '/includes/asset_version.php';
$publicDir = __DIR__;
$vCss = publicAssetVersion($publicDir, 'assets/css/app.css');
$vJs = publicAssetVersion($publicDir, 'assets/js/app.js');
$vMenu = publicAssetVersion($publicDir, 'menu.json');
?><!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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) ?> -->
<title>Karczma Biesiada Twoje Zamówienie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="assets/css/app.css?v=<?= assetVersionAttr($vCss) ?>">
</head>
<body>
<div id="loadingScreen">
<div class="loader-icon"></div>
<div class="loader-text">
<h2>Karczma Biesiada</h2>
<div class="loader-msg" id="loaderMsg">Łączenie z kuchnią...</div>
</div>
</div>
<div id="geoScreen" class="hidden">
<div class="geo-shell">
<div class="geo-icon">📍</div>
<h2 class="geo-title">Witamy w Karcznie</h2>
<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">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">Zgoda, sprawdź lokalizację</span>
</button>
</div>
<div class="geo-instructions hidden" id="geoInstructions" aria-live="polite"></div>
<div class="geo-wifi-callout">
<p class="geo-wifi-callout-title">📶 Wejdź bez zgody na lokalizację</p>
<p>Połącz telefon z siecią WiFi restauracji:</p>
<p class="geo-wifi-network"><strong>HotSpot Karczmy</strong></p>
<p class="geo-wifi-password">Hasło: <strong>karczmabiesiada</strong></p>
<p>Po połączeniu <strong>odśwież stronę</strong> (lub zamknij i otwórz ponownie kod QR). Aplikacja wpuści Cię <strong>bez pytania o lokalizację</strong> — z pełnym dostępem do:</p>
<ul class="geo-wifi-list">
<li>wezwania kelnera</li>
<li>prośby o rachunek</li>
<li>statusu zamówienia</li>
<li>całej aplikacji przy stoliku</li>
</ul>
</div>
</div>
</div>
<div class="container">
<header id="mainHeader">
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="tableLabel" class="table-badge">Wybierz stolik</div>
</header>
<!-- <div id="greetingBanner"
style="display:none; text-align:center; padding: 10px; font-weight:600; color:var(--primary); font-family:'Playfair Display', serif; font-size:18px;">
</div> -->
<main id="mainContent">
<div id="statusView" class="view-section active">
<section class="status-card">
<div class="status-header">
<div>
<span class="status-title">Aktualny status</span>
<div id="prepStatus" class="status-value">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);">
Sprawdzamy co pysznego się przygotowuje...
</div>
</section>
<section class="items-container" id="ordersContainer">
<h3 id="ordersTitle">Twoje zamówione dania</h3>
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon">📖</div>
<p style="color: var(--text-muted)">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')">Przeglądaj menu</button>
</div>
<div id="itemsList"></div>
</section>
<section id="historySection" class="items-container history-section hidden">
<h3>Twoje poprzednie zamówienia</h3>
<p class="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;">Usuń historię</a>
</div>
</section>
<div id="metaFooter" class="meta-footer"></div>
</div> <!-- Koniec statusView -->
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner is-hidden">
<span>Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()">Sprawdź teraz</button>
</div>
<div class="menu-search-container">
<input type="text" id="menuSearchInput" placeholder="Szukaj dania..." oninput="filterMenu()" />
</div>
<div class="restaurant-menu-container">
<nav class="menu-categories-nav">
<ul>
<li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0)">Wszystko</a></li>
<li><a href="#" onclick="showCategory(1)" data-category-badge="1">Przystawki</a></li>
<li><a href="#" onclick="showCategory(2)" data-category-badge="2">Zupy</a></li>
<li><a href="#" onclick="showCategory(3)" data-category-badge="3">Dania główne</a></li>
<li><a href="#" onclick="showCategory(4)" data-category-badge="4">Dania swojskie</a></li>
<li><a href="#" onclick="showCategory(5)" data-category-badge="5">Ryby</a></li>
<li><a href="#" onclick="showCategory(7)" data-category-badge="7">Sałatki</a></li>
<li><a href="#" onclick="showCategory(6)" data-category-badge="6">Makarony</a></li>
<li><a href="#" onclick="showCategory(9)" data-category-badge="9">Dla dzieci</a></li>
<li><a href="#" onclick="showCategory(8)" data-category-badge="8">Dodatki</a></li>
<li><a href="#" onclick="showCategory(10)" data-category-badge="10">Desery</a></li>
<li><a href="#" onclick="showCategory(11)" data-category-badge="11">Napoje</a></li>
</ul>
</nav>
<div class="restaurant-menu-scroll" id="menuContainer">
<!-- Dynamiczne menu załaduje się tutaj -->
</div>
</div>
</div> <!-- Koniec menuView -->
</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>
<!-- Bottom Navigation Bar -->
<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">Zamówienie</span>
</div>
<div class="nav-item" onclick="switchTab('menu')" id="navMenu">
<span class="nav-icon">📖</span>
<span class="nav-label">Menu</span>
</div>
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
<span class="nav-icon">🛎️</span>
<span class="nav-label">Kelner</span>
</div>
<div class="nav-item action-bill" onclick="openBillDialog()" id="navBill">
<span class="nav-icon">💳</span>
<span class="nav-label">Rachunek</span>
</div>
</nav>
<!-- WAITER DIALOG -->
<div class="modal-overlay" id="waiterModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">🛎️</div>
<h3 style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px;">
Przywołać obsługę?</h3>
<p style="color: var(--text-muted); font-size: 15px; margin-bottom: 25px; line-height: 1.5;">
Kelner otrzyma natychmiastowe powiadomienie na swoim panelu i podejdzie do Twojego stolika najszybciej jak to
możliwe.
</p>
<div style="display: flex; gap: 12px; flex-direction: column;">
<button class="btn btn-primary" onclick="confirmCallWaiter()" style="padding: 14px; font-size: 16px;">Tak,
poproś kelnera</button>
<button class="btn btn-secondary" onclick="closeWaiterDialog()" style="padding: 14px;">Anuluj</button>
</div>
</div>
</div>
<!-- NAME DIALOG (Tymczasowo wyłączone)
<div class="modal-overlay" id="nameModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">👋</div>
<h3 style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px;">Podaj
swoje imię</h3>
<p style="color: var(--text-muted); font-size: 15px; margin-bottom: 20px; line-height: 1.5;">
Dzięki temu będziemy mogli powitać Cię osobiście podczas Twojej wizyty!
</p>
<div class="input-group" style="margin-bottom: 25px; text-align: left;">
<input type="text" id="userNameInput" class="input-field" placeholder="Twoje imię..." autocomplete="off" />
</div>
<div style="display: flex; gap: 12px; flex-direction: column;">
<button class="btn btn-primary" onclick="saveUserName()" style="padding: 14px; font-size: 16px;">Idę
dalej</button>
<button class="btn btn-secondary" onclick="declineUserName()" style="padding: 14px;">Nie chcę podawać</button>
</div>
</div>
</div>
-->
<!-- ITEM MODAL -->
<div class="modal-overlay" id="itemModal">
<div class="modal-content" style="padding: 0; overflow: hidden; max-width: 380px;">
<div style="position: relative;">
<img id="itemModalImage" src="" style="width: 100%; height: 240px; object-fit: cover; display: block;" alt="">
<button class="close-btn" onclick="closeItemModal()" style="position: absolute; top: 10px; right: 15px; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,0.8); font-size: 32px; z-index: 10; background: rgba(0,0,0,0.3); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; line-height: 1;">&times;</button>
</div>
<div style="padding: 24px; text-align: left;">
<h3 id="itemModalTitle" style="margin-top: 0; color: var(--text-main); font-family: 'Playfair Display', serif; font-size: 24px; margin-bottom: 8px;"></h3>
<div id="itemModalPrice" style="color: var(--primary); font-weight: 700; font-size: 20px; margin-bottom: 16px;"></div>
<p id="itemModalDesc" style="color: var(--text-muted); font-size: 15px; line-height: 1.6; margin-bottom: 24px;"></p>
<button class="btn btn-secondary" onclick="closeItemModal()" style="width: 100%;">Zamknij</button>
</div>
</div>
</div>
<!-- BILL DIALOG -->
<div class="modal-overlay" id="billModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Rozliczenie</h3>
<button class="close-btn" onclick="closeBillDialog()">&times;</button>
</div>
<!-- Step 0: Loading or List of Bills -->
<div class="step active" id="stepBillList">
<div id="billLoading" style="text-align:center; padding: 20px;">
⏳ Pobieranie rachunków...
</div>
<div id="billListContainer" class="hidden">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Mamy kilka otwartych
rachunków na tym stoliku. Który chcesz opłacić?</p>
<div id="billListItems" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
</div>
<!-- Step 0.5: Bill Review -->
<div class="step" id="stepBillReview">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Podsumowanie rachunku:
</p>
<div id="billReviewContent"
style="background: var(--surface-light); padding: 15px; border-radius: 12px; max-height: 40vh; overflow-y: auto; margin-bottom: 15px;">
</div>
<div style="display:flex; justify-content:space-between; font-weight:700; font-size:18px; margin-bottom: 20px;">
<span>Do zapłaty:</span>
<span id="billTotalAmount" style="color:var(--primary);">0.00 PLN</span>
</div>
<div style="display:flex; gap:12px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goBackToBillList()"
id="btnBackToBills">Wróć</button>
<button class="btn btn-primary" style="flex:2;" onclick="proceedToBillPayment()">Poproś rachunek</button>
</div>
</div>
<!-- Step 1: Payment Method -->
<div class="step" id="stepPayment">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Wybierz preferowaną formę
płatności:</p>
<div class="option-grid">
<div class="option-card" onclick="selectPayment('karta')">
<span class="option-icon">💳</span>
<span class="option-label">Karta</span>
</div>
<div class="option-card" onclick="selectPayment('gotówka')">
<span class="option-icon">💵</span>
<span class="option-label">Gotówka</span>
</div>
</div>
<button class="btn btn-secondary" onclick="goToStep('stepBillReview')" style="margin-top: 15px;">Wróć do
podsumowania</button>
</div>
<!-- Step 2: Document Type -->
<div class="step" id="stepDocument">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Jakiego dokumentu
potrzebujesz?</p>
<div class="option-grid">
<div class="option-card" onclick="selectDocument('paragon')">
<span class="option-icon">🧾</span>
<span class="option-label">Paragon</span>
</div>
<div class="option-card" onclick="selectDocument('faktura')">
<span class="option-icon">📄</span>
<span class="option-label">Faktura</span>
</div>
</div>
<button class="btn btn-secondary" onclick="goToStep('stepPayment')" style="margin-top: 8px;">Wróć</button>
</div>
<!-- Step 3: NIP Input -->
<div class="step" id="stepNIP">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Wprowadź NIP firmy,
abyśmy mogli automatycznie pobrać dane.</p>
<div class="input-group">
<label class="input-label">Numer NIP</label>
<input type="number" id="nipInput" class="input-field" placeholder="np. 1234567890" autocomplete="off" />
</div>
<div style="display:flex; gap:12px; margin-top: 24px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goToStep('stepDocument')">Wróć</button>
<button class="btn btn-primary" style="flex:2;" onclick="fetchGUS()" id="btnGUS">Pobierz z GUS</button>
</div>
</div>
<!-- Step 4: Verify Data -->
<div class="step" id="stepVerify">
<p style="margin-top:0; color:var(--text-muted); font-size:14px; margin-bottom: 20px;">Czy poniższe dane do
faktury są prawidłowe?</p>
<div class="company-details">
<input type="text" id="cmpName" class="company-input" style="font-weight:700; margin-bottom:4px;" readonly />
<input type="text" id="cmpStreet" class="company-input" placeholder="Ulica i numer" style="margin-bottom:4px;"
readonly />
<div style="display: flex; gap: 8px; margin-bottom: 4px;">
<input type="text" id="cmpZip" class="company-input" placeholder="Kod" style="flex: 1;" readonly />
<input type="text" id="cmpCity" class="company-input" placeholder="Miasto" style="flex: 2;" readonly />
</div>
<input type="text" id="cmpNip" class="company-input muted" readonly />
</div>
<div style="display:flex; gap:12px; flex-direction:column;">
<button class="btn btn-primary" onclick="confirmInvoice()">Tak, poproszę fakturę!</button>
<div style="display:flex; gap:12px;">
<button class="btn btn-secondary" onclick="goToStep('stepNIP')">Zmień NIP</button>
<button class="btn btn-secondary" onclick="editCompanyData()" id="btnEditCompany">Popraw ręcznie</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toastMsg">
<span style="font-size:20px;">✓</span> <span id="toastText">Wysłano!</span>
</div>
<script>
window.MENU_ASSET_VERSION = <?= json_encode($vMenu, JSON_UNESCAPED_UNICODE) ?>;
window.APP_JS_VERSION = <?= json_encode($vJs, JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/app.js?v=<?= assetVersionAttr($vJs) ?>"></script>
</body>
</html>

View File

@@ -71,33 +71,191 @@ body {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
padding: 24px 20px;
text-align: center;
transition: opacity 0.5s ease, visibility 0.5s;
overflow-y: auto;
}
.geo-shell {
width: 100%;
max-width: 400px;
}
.geo-icon {
font-size: 80px;
margin-bottom: 16px;
font-size: 64px;
margin-bottom: 12px;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
50% { transform: translateY(-10px); }
}
.geo-text h2 {
.geo-title {
font-family: 'Playfair Display', serif;
margin: 0 0 12px;
margin: 0 0 10px;
color: var(--primary);
font-size: 1.65rem;
line-height: 1.2;
}
.geo-lead {
color: var(--text-muted);
font-size: 15px;
line-height: 1.45;
margin: 0;
}
.geo-status {
color: var(--text-muted);
font-size: 14px;
line-height: 1.45;
margin: 14px 0 0;
min-height: 0;
}
.geo-status:empty {
display: none;
}
.geo-status.is-error {
color: #ff8a8a;
font-weight: 600;
}
.geo-status.is-info {
color: var(--primary);
}
.geo-msg {
.geo-actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 10px;
margin-top: 20px;
width: 100%;
}
.geo-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 76px;
padding: 12px 10px;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
line-height: 1.25;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.geo-btn:active {
transform: scale(0.98);
}
.geo-btn-main {
font-size: 14px;
font-weight: 700;
}
.geo-btn-sub {
font-size: 11px;
font-weight: 500;
opacity: 0.85;
}
.geo-btn-menu {
background: rgba(255, 255, 255, 0.03);
border: 1.5px solid rgba(226, 176, 126, 0.25);
color: var(--text-muted);
font-size: 15px;
}
.geo-btn-menu .geo-btn-main {
color: var(--text-main);
}
.geo-btn-locate {
border: none;
box-shadow: 0 4px 18px rgba(226, 176, 126, 0.28);
}
.geo-btn-locate .geo-btn-main {
font-size: 13px;
line-height: 1.3;
}
.geo-btn-locate:disabled {
opacity: 0.65;
cursor: wait;
transform: none;
}
.geo-instructions {
margin-top: 16px;
padding: 14px 16px;
background: var(--surface-light);
border: 1px solid rgba(226, 176, 126, 0.2);
border-radius: 12px;
font-size: 13px;
line-height: 1.55;
color: #d1d5db;
text-align: left;
}
.geo-instructions.hidden {
display: none;
}
.geo-wifi-callout {
margin: 20px 0 0;
padding: 16px 18px;
background: rgba(226, 176, 126, 0.1);
border: 1.5px solid rgba(226, 176, 126, 0.35);
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
color: #e8e8ea;
text-align: left;
}
.geo-wifi-callout-title {
margin: 0 0 10px;
font-size: 16px;
font-weight: 700;
color: var(--primary);
}
.geo-wifi-callout p {
margin: 0 0 8px;
}
.geo-wifi-network,
.geo-wifi-password {
font-size: 15px;
color: var(--text-main);
}
.geo-wifi-list {
margin: 8px 0 0;
padding-left: 20px;
color: #d1d5db;
}
.geo-wifi-list li {
margin-bottom: 4px;
}
.geo-wifi-list li:last-child {
margin-bottom: 0;
}
@media (max-width: 360px) {
.geo-actions {
grid-template-columns: 1fr;
}
}
/* --- MAIN LAYOUT --- */
@@ -907,6 +1065,10 @@ header {
line-height: 1.45;
}
.menu-only-banner.is-hidden {
display: none !important;
}
.menu-only-banner-btn {
flex-shrink: 0;
border: 1px solid rgba(226, 176, 126, 0.45);
@@ -944,6 +1106,10 @@ header {
margin-bottom: 10px;
}
#menuOnlyBanner.is-hidden + .menu-search-container {
padding-top: 0;
}
#menuSearchInput {
width: 100%;
background: var(--surface);

View File

@@ -7,6 +7,9 @@ window.kitchenAnimations = [
];
window.selectedAnimationHtml = null;
const MENU_ASSET_VERSION =
window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1";
const params = new URLSearchParams(location.search);
let hashParam = (params.get("h") || "").trim();
const isStaffPreview = params.get("preview") === "staff";
@@ -454,7 +457,10 @@ function updateNavAccessState() {
if (el) el.classList.toggle("nav-locked", locked);
});
const banner = document.getElementById("menuOnlyBanner");
if (banner) banner.classList.toggle("hidden", appAccessLevel !== "menu");
if (banner) {
banner.classList.remove("hidden");
banner.classList.toggle("is-hidden", appAccessLevel !== "menu");
}
}
function showBottomNav() {
@@ -479,46 +485,156 @@ async function resolveTableLabel() {
}
}
function isIOSDevice() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
const GEO_GATE_LABELS = {
status: "status zamówienia",
waiter: "wezwanie kelnera",
bill: "prośbę o rachunek",
};
const GEO_DEFAULT_LEAD =
"Przeglądaj menu od razu — albo potwierdź, że jesteś u nas, aby wezwać kelnera, śledzić zamówienie i poprosić o rachunek.";
function setGeoLead(html) {
const el = document.getElementById("geoLead");
if (el) el.innerHTML = html;
}
function setGeoStatus(html, { error = false, info = false } = {}) {
const el = document.getElementById("geoMsg");
if (!el) return;
el.innerHTML = html || "";
el.classList.toggle("is-error", error);
el.classList.toggle("is-info", info);
}
function showGeoInstructions(html) {
const el = document.getElementById("geoInstructions");
if (!el) return;
el.innerHTML = html || "";
el.classList.toggle("hidden", !html);
}
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("Sprawdzanie…");
}
}
function isGeoPermissionDenied(error) {
return Number(error?.code) === 1;
}
function setGeoActionLabel(text) {
const btn = document.getElementById("geoActionBtn");
if (!btn) return;
const main = btn.querySelector(".geo-btn-main");
if (main) main.textContent = text;
else btn.textContent = text;
}
function getGeoPermissionInstructions() {
if (isIOSDevice()) {
return `<b>iPhone (Safari):</b><br>
1. Kliknij <b>aA</b> po lewej stronie paska adresu.<br>
2. Wybierz <b>Ustawienia witryny</b>.<br>
3. Ustaw <b>Położenie</b> na „Zapytaj” lub „Pozwalaj”.<br>
4. Odśwież stronę.<br><br>
<i>Lokalizacja działa tylko przez bezpieczne <b>https://</b>.</i>`;
}
return `<b>Android / Chrome:</b><br>
1. Kliknij ikonę <b>kłódki</b> obok adresu strony.<br>
2. W <b>Uprawnieniach</b> zmień Lokalizację na „Zezwalaj”.<br>
3. Odśwież stronę.`;
}
let geoMenuButtonMode = "menu_only";
window.handleGeoMenuClick = function () {
if (geoMenuButtonMode === "back_to_menu") {
document.getElementById("geoScreen")?.classList.add("hidden");
return;
}
enterMenuOnlyMode();
};
window.retryGeolocation = function () {
if (shouldBypassGeolocationHost()) {
bypassGeolocation("trusted_host", { host: window.location.hostname });
return;
}
initGeolocationAfterBypassChecks({ userInitiated: true }).catch((err) => {
console.error("[GEO] retry failed", err);
showGeoPermissionBlockedState();
});
};
function bindGeoScreenButtons() {
document.getElementById("geoMenuOnlyBtn")?.addEventListener("click", (event) => {
event.preventDefault();
handleGeoMenuClick();
});
document.getElementById("geoActionBtn")?.addEventListener("click", (event) => {
event.preventDefault();
retryGeolocation();
});
}
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 = "";
menuOnlyBtn.textContent = "Wróć do menu";
menuOnlyBtn.onclick = () => {
document.getElementById("geoScreen")?.classList.add("hidden");
};
if (mainEl) mainEl.textContent = "Wróć do menu";
if (subEl) {
subEl.textContent = "";
subEl.style.display = "none";
}
return;
}
menuOnlyBtn.style.display = "";
menuOnlyBtn.textContent = "Przeglądaj menu bez lokalizacji";
menuOnlyBtn.onclick = () => enterMenuOnlyMode();
if (mainEl) mainEl.textContent = "Przejdź do menu";
if (subEl) {
subEl.textContent = "bez lokalizacji";
subEl.style.display = "";
}
}
function showGeoGateForAction(action) {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
const geoActionBtn = document.getElementById("geoActionBtn");
if (loadingScreen) loadingScreen.classList.add("hidden");
if (geoScreen) geoScreen.classList.remove("hidden");
const feature = GEO_GATE_LABELS[action] || "tę funkcję";
if (geoMsg) {
geoMsg.innerHTML = `Aby skorzystać z <b>${feature}</b>, potwierdź, że jesteś w restauracji.<br><br>Prosimy o zgodę na dostęp do lokalizacji.`;
}
setGeoLead(`Menu masz już otwarte. Aby skorzystać z <b>${feature}</b>, potwierdź krótko, że jesteś w restauracji.`);
setGeoStatus("");
hideGeoInstructions();
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Sprawdź lokalizację";
setGeoActionBusy(false);
setGeoActionLabel("Sprawdź lokalizację");
}
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
}
@@ -1322,7 +1438,7 @@ window.fetchGUS = async function () {
// --- DYNAMIC MENU LOADING ---
async function loadMenu() {
try {
const response = await fetch('menu.json');
const response = await fetch(`menu.json?v=${encodeURIComponent(MENU_ASSET_VERSION)}`);
if (!response.ok) throw new Error('Nie udało się załadować menu');
const menuData = await response.json();
window.menuDataRaw = menuData;
@@ -1467,9 +1583,13 @@ window.confirmInvoice = async function () {
};
// --- GEOLOCATION LOGIC ---
const RESTAURANT_LAT = 50.5624963;
const RESTAURANT_LNG = 22.0608059;
const MAX_DISTANCE_METERS = 200;
// Dwa punkty odniesienia: OSM (adres budynku) i pin Google Maps (z nim porównują goście w Maps).
const RESTAURANT_LOCATIONS = [
{ lat: 50.5622609, lng: 22.0606303, source: "osm" },
{ lat: 50.567953, lng: 22.061045, source: "google_maps" },
];
const MAX_DISTANCE_METERS = 300;
const MAX_ACCURACY_BONUS = 150;
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3;
@@ -1485,6 +1605,76 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
return R * c;
}
function distanceToRestaurant(lat, lng) {
return Math.min(
...RESTAURANT_LOCATIONS.map(({ lat: rLat, lng: rLng }) =>
haversineDistance(rLat, rLng, lat, lng)
)
);
}
function isInsideRestaurantGeofence(distanceMeters, accuracyMeters) {
const accuracyBonus = Math.min(Math.max(Number(accuracyMeters) || 0, 0), MAX_ACCURACY_BONUS);
return distanceMeters <= MAX_DISTANCE_METERS + accuracyBonus;
}
function requestRestaurantGeolocation() {
const geoOptions = {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 0,
};
return new Promise((resolve, reject) => {
let bestSample = null;
let watchId = null;
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
if (watchId != null) navigator.geolocation.clearWatch(watchId);
clearTimeout(timeoutId);
resolve(result);
};
const fail = (error) => {
if (settled) return;
settled = true;
if (watchId != null) navigator.geolocation.clearWatch(watchId);
clearTimeout(timeoutId);
reject(error);
};
const handlePosition = (position) => {
const dist = distanceToRestaurant(position.coords.latitude, position.coords.longitude);
const accuracy = position.coords.accuracy;
const sample = { position, dist, accuracy };
if (!bestSample || dist < bestSample.dist) {
bestSample = sample;
}
if (isInsideRestaurantGeofence(dist, accuracy)) {
finish({ passed: true, ...sample });
}
};
const timeoutId = setTimeout(() => {
if (bestSample) {
finish({
passed: isInsideRestaurantGeofence(bestSample.dist, bestSample.accuracy),
...bestSample,
});
return;
}
fail({ code: 3, message: "Geolocation timeout" });
}, 12000);
watchId = navigator.geolocation.watchPosition(handlePosition, fail, geoOptions);
});
}
function startApp() {
appAccessLevel = "full";
updateNavAccessState();
@@ -1512,28 +1702,30 @@ function startApp() {
function showGeoConsentScreen() {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
const geoActionBtn = document.getElementById("geoActionBtn");
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
if (geoMsg) {
geoMsg.innerHTML = `Aby zapewnić bezpieczeństwo Twojego zamówienia, musimy upewnić się, że znajdujesz się na terenie restauracji.<br><br>Prosimy o udzielenie zgody na dostęp do lokalizacji w przeglądarce.`;
}
setGeoLead(GEO_DEFAULT_LEAD);
setGeoStatus("");
hideGeoInstructions();
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Udziel zgody / Sprawdź";
}
if (menuOnlyBtn) {
configureGeoSecondaryButton("menu_only");
setGeoActionBusy(false);
setGeoActionLabel("Zgoda, sprawdź lokalizację");
}
configureGeoSecondaryButton("menu_only");
}
function isIOSDevice() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
function showGeoPermissionBlockedState() {
setGeoStatus("Przeglądarka zablokowała dostęp do lokalizacji.", { error: true });
showGeoInstructions(
`${getGeoPermissionInstructions()}<br><br>Po zmianie ustawień <b>odśwież stronę</b>, a potem kliknij „Spróbuj ponownie”.`
);
setGeoActionBusy(false);
setGeoActionLabel("Spróbuj ponownie");
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
}
function shouldBypassGeolocationHost() {
@@ -1541,135 +1733,189 @@ function shouldBypassGeolocationHost() {
return bypassHosts.includes(window.location.hostname);
}
window.initGeolocation = function () {
if (shouldBypassGeolocationHost()) {
console.warn("Bypassing geolocation for trusted host.");
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "trusted_host" });
async function checkGeoBypassByClientIp() {
try {
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), 4000)
: null;
const res = await fetch("../api/geo_bypass.php", {
credentials: "same-origin",
cache: "no-store",
signal: controller?.signal,
});
if (timeoutId) clearTimeout(timeoutId);
const data = await res.json();
return data.status === "success" && data.bypassGeo === true;
} catch {
return false;
}
}
function bypassGeolocation(reason, extra = {}) {
trackEvent('geo_bypass_host', { reason, ...extra });
if (appAccessLevel === 'menu') {
unlockFullApp();
} else {
startApp();
}
}
async function queryGeolocationPermissionState() {
if (!navigator.permissions?.query) {
return "unknown";
}
try {
const status = await navigator.permissions.query({ name: "geolocation" });
return status.state;
} catch {
return "unknown";
}
}
async function bootstrapGeolocation() {
if (shouldBypassGeolocationHost()) {
bypassGeolocation('trusted_host', { host: window.location.hostname });
return;
}
if (await checkGeoBypassByClientIp()) {
bypassGeolocation('trusted_ip');
return;
}
showGeoConsentScreen();
}
window.initGeolocation = function () {
if (shouldBypassGeolocationHost()) {
console.warn("Bypassing geolocation for trusted host.");
bypassGeolocation("trusted_host", { host: window.location.hostname });
return;
}
checkGeoBypassByClientIp().then((bypassByIp) => {
if (bypassByIp) {
console.warn("Bypassing geolocation for trusted client IP.");
bypassGeolocation("trusted_ip");
return;
}
initGeolocationAfterBypassChecks();
});
};
async function initGeolocationAfterBypassChecks(options = {}) {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
const geoActionBtn = document.getElementById("geoActionBtn");
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84'];
if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) {
console.warn("Bypassing geolocation on local HTTP environment.");
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "local_http" });
unlockFullApp();
bypassGeolocation("local_http", { host: window.location.hostname });
return;
}
if (!window.isSecureContext) {
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Ta strona nie jest uruchomiona w bezpiecznym trybie HTTPS.</b><br><br>
Przeglądarki mobilne blokują geolokalizację bez pytania, jeśli adres nie zaczyna się od <b>https://</b>.<br><br>
Otwórz aplikację przez HTTPS i spróbuj ponownie.<br><br>
<b>Masz problem z lokalizacją?</b> Połącz się z <b>HotSpot Karczmy</b>, a wtedy wejdziesz do aplikacji bez geolokalizacji.<br>
Hasło: <b>karczmabiesiada</b>`;
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Spróbuj ponownie";
}
if (menuOnlyBtn) configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
return;
}
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
loadingScreen?.classList.add("hidden");
geoScreen?.classList.remove("hidden");
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
if (!navigator.geolocation) {
geoMsg.innerHTML = "Twoja przeglądarka nie wspiera geolokalizacji. Aplikacja wymaga nowszej przeglądarki.";
if (!window.isSecureContext) {
setGeoLead(GEO_DEFAULT_LEAD);
setGeoStatus("Ta strona wymaga bezpiecznego połączenia HTTPS.", { error: true });
showGeoInstructions("Przeglądarki mobilne blokują geolokalizację bez <b>https://</b>. Otwórz aplikację przez HTTPS i spróbuj ponownie.");
setGeoActionBusy(false);
setGeoActionLabel("Spróbuj ponownie");
return;
}
geoMsg.innerHTML = "Sprawdzamy Twoją lokalizację...";
trackEvent("geo_check_started");
if (geoActionBtn) {
geoActionBtn.disabled = true;
geoActionBtn.textContent = "Sprawdzanie...";
if (!navigator.geolocation) {
setGeoLead(GEO_DEFAULT_LEAD);
setGeoStatus("Twoja przeglądarka nie wspiera geolokalizacji.", { error: true });
hideGeoInstructions();
setGeoActionBusy(false);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const dist = haversineDistance(
RESTAURANT_LAT, RESTAURANT_LNG,
position.coords.latitude, position.coords.longitude
);
setGeoLead(GEO_DEFAULT_LEAD);
setGeoStatus("Sprawdzamy Twoją lokalizację…", { info: true });
hideGeoInstructions();
setGeoActionBusy(true);
const accuracy = position.coords.accuracy;
console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`);
const permissionState = await queryGeolocationPermissionState();
if (permissionState === "denied") {
showGeoPermissionBlockedState();
return;
}
if (dist <= MAX_DISTANCE_METERS) {
trackEvent("geo_check_passed", { distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
unlockFullApp();
// setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000);
} else {
trackEvent("geo_check_failed", { reason: "outside_restaurant", distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Spróbuj ponownie";
}
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
geoMsg.innerHTML = `Wydaje się, że jesteś poza restauracją (ok. ${Math.round(dist)}m od nas).<br>Nasza aplikacja działa tylko na miejscu.<br><br>
<small style="color: #888;">Debug: Twoja odległość: ${Math.round(dist)}m, Dokładność sygnału: ${Math.round(accuracy)}m</small><br><br>
Jeśli to błąd GPS lub słaby sygnał, spróbuj ponownie za chwilę.`;
}
},
(error) => {
trackEvent("geo_check_failed", { reason: "browser_error", code: error.code || null, message: String(error.message || "") });
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Spróbuj ponownie";
}
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || ""));
if (deniedBecauseInsecure) {
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Przeglądarka zablokowała lokalizację z powodu braku HTTPS.</b><br><br>
Geolokalizacja działa tylko na bezpiecznym adresie <b>https://</b> (lub localhost).<br>
Otwórz aplikację przez HTTPS i spróbuj ponownie.<br><br>
<b>Masz problem z lokalizacją?</b> Połącz się z <b>HotSpot Karczmy</b>, a wtedy wejdziesz do aplikacji bez geolokalizacji.<br>
Hasło: <b>karczmabiesiada</b>`;
} else if (error.code === error.PERMISSION_DENIED) {
const isIOS = isIOSDevice();
let instructions = '';
if (isIOS) {
instructions = `<b>Instrukcja dla iPhone (Safari):</b><br>
1. Kliknij ikonę <b>"aA"</b> po lewej stronie paska adresu.<br>
2. Wybierz <b>"Ustawienia witryny"</b> (Website Settings).<br>
3. Zmień opcję <b>"Położenie"</b> (Location) na "Zapytaj" lub "Pozwalaj".<br>
4. Odśwież stronę.<br><br>
<i>Uwaga: Na urządzeniach Apple lokalizacja działa WYŁĄCZNIE, gdy adres strony zaczyna się od bezpiecznego <b>https://</b>. Jeżeli jesteś na http://, system zablokuje to automatycznie.</i><br><br>
<b>Masz problem z lokalizacją?</b> Połącz się z <b>HotSpot Karczmy</b>, a wtedy wejdziesz do aplikacji bez geolokalizacji.<br>
Hasło: <b>karczmabiesiada</b>`;
} else {
instructions = `<b>Instrukcja dla Android / Chrome:</b><br>
1. Kliknij ikonkę <b>kłódki / ustawień</b> 🔒 obok adresu strony na górze przeglądarki.<br>
2. Znajdź <b>Uprawnienia</b> (Lokalizacja) i zmień z "Zablokuj" na "Zezwalaj".<br>
3. Odśwież stronę.<br><br>
<b>Masz problem z lokalizacją?</b> Połącz się z <b>HotSpot Karczmy</b>, a wtedy wejdziesz do aplikacji bez geolokalizacji.<br>
Hasło: <b>karczmabiesiada</b>`;
}
trackEvent("geo_check_started");
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Przeglądarka zablokowała dostęp do lokalizacji.</b><br><br>
Bez tego nie możemy zweryfikować, czy jesteś w restauracji.<br><br>
${instructions}`;
} else {
geoMsg.innerHTML = "Nie udało się pobrać lokalizacji. Sprawdź zasięg lub włącz GPS i spróbuj ponownie.";
}
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
try {
const result = await requestRestaurantGeolocation();
const dist = result.dist;
const accuracy = result.accuracy;
console.log(
`[GEO] Lat: ${result.position.coords.latitude}, Lng: ${result.position.coords.longitude}, ` +
`MinDist: ${Math.round(dist)}m, Accuracy: ${Math.round(accuracy)}m`
);
if (result.passed) {
trackEvent("geo_check_passed", {
distanceMeters: Math.round(dist),
accuracyMeters: Math.round(accuracy),
});
unlockFullApp();
return;
}
trackEvent("geo_check_failed", {
reason: "outside_restaurant",
distanceMeters: Math.round(dist),
accuracyMeters: Math.round(accuracy),
});
setGeoActionBusy(false);
setGeoActionLabel("Spróbuj ponownie");
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
setGeoStatus(
`Wygląda na to, że jesteś poza restauracją (ok. ${Math.round(dist)} m, dokładność GPS: ±${Math.round(accuracy)} m).`,
{ error: true }
);
showGeoInstructions(
"Przeglądarka często podaje inną lokalizację niż aplikacja Map Google. " +
"Spróbuj ponownie na zewnątrz lub bliżej okna — albo przejdź do menu bez lokalizacji."
);
} catch (error) {
trackEvent("geo_check_failed", {
reason: "browser_error",
code: error.code || null,
message: String(error.message || ""),
});
setGeoActionBusy(false);
setGeoActionLabel("Spróbuj ponownie");
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || ""));
if (deniedBecauseInsecure) {
setGeoStatus("Geolokalizacja wymaga HTTPS.", { error: true });
showGeoInstructions("Otwórz aplikację przez bezpieczny adres <b>https://</b> i spróbuj ponownie.");
} else if (isGeoPermissionDenied(error)) {
showGeoPermissionBlockedState();
} else {
setGeoStatus("Nie udało się pobrać lokalizacji.", { error: true });
showGeoInstructions("Sprawdź zasięg, włącz GPS i spróbuj ponownie.");
}
}
};
bindGeoScreenButtons();
if (shouldBypassGeolocationHost()) {
startApp();
} else if (isIOSDevice()) {
showGeoConsentScreen();
bypassGeolocation("trusted_host", { host: window.location.hostname });
} else {
initGeolocation();
bootstrapGeolocation();
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Wersja statycznego pliku w public/ — timestamp modyfikacji (cache busting po FTP).
*/
function publicAssetVersion(string $publicDir, string $relativePath): string
{
$path = $publicDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/'));
if (!is_file($path)) {
return '0';
}
$mtime = filemtime($path);
return $mtime !== false ? (string) $mtime : '0';
}
function assetVersionAttr(string $version): string
{
return htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
}

291
public/waiter/app.css Normal file
View File

@@ -0,0 +1,291 @@
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #3b82f6;
--danger: #ef4444;
--warning: #f59e0b;
--success: #22c55e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 16px;
padding-bottom: max(24px, env(safe-area-inset-bottom));
}
.hidden {
display: none !important;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.top-bar h1 {
font-size: 1.45rem;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 4px;
font-size: 0.85rem;
color: var(--muted);
}
.sync-pill {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
white-space: nowrap;
flex-shrink: 0;
}
.sync-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.sync-dot.ok {
background: var(--success);
box-shadow: 0 0 8px var(--success);
animation: pulse 2s infinite;
}
.sync-dot.err {
background: var(--danger);
box-shadow: 0 0 8px var(--danger);
}
@keyframes pulse {
0%, 100% { opacity: 0.75; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1.1); }
}
.notify-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1e3a5f 0%, #1e293b 100%);
border: 1px solid #2563eb;
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 16px;
}
.notify-banner p {
margin-top: 4px;
font-size: 0.82rem;
color: #cbd5e1;
line-height: 1.4;
}
.btn {
border: none;
border-radius: 10px;
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 800;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
}
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--muted);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
.feed-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
animation: slideIn 0.25s ease-out;
}
.feed-card.is-new {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
}
.feed-card.waiter {
border-left: 5px solid var(--danger);
}
.feed-card.bill {
border-left: 5px solid var(--warning);
}
.feed-card.done {
opacity: 0.72;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.feed-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
.feed-title {
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.feed-card.waiter .feed-title {
color: #fca5a5;
}
.feed-card.bill .feed-title {
color: #fcd34d;
}
.feed-time {
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
}
.feed-table {
font-size: 1.05rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 6px;
}
.feed-operator {
font-size: 0.88rem;
color: var(--muted);
margin-bottom: 8px;
}
.feed-operator strong {
color: #e2e8f0;
}
.feed-msg {
font-size: 0.9rem;
color: #cbd5e1;
line-height: 1.45;
white-space: pre-line;
}
.feed-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--border);
}
.badge-pending {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.35);
}
.badge-done {
background: rgba(34, 197, 94, 0.12);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
@media (min-width: 640px) {
body {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}

249
public/waiter/app.js Normal file
View File

@@ -0,0 +1,249 @@
const API_URL = '../../api/waiter_feed.php';
const POLL_MS = 15000;
const feedList = document.getElementById('feedList');
const emptyState = document.getElementById('emptyState');
const syncDot = document.getElementById('syncDot');
const syncLabel = document.getElementById('syncLabel');
const notifyBanner = document.getElementById('notifyBanner');
const enableNotifyBtn = document.getElementById('enableNotifyBtn');
const statPending = document.getElementById('statPending');
const statWaiter = document.getElementById('statWaiter');
const statBill = document.getElementById('statBill');
const statTotal = document.getElementById('statTotal');
let knownIds = new Set();
let feedInitialized = false;
let pollTimer = null;
let lastPayload = '';
let swRegistration = null;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function formatTime(isoLike) {
if (!isoLike) return '';
const dt = new Date(String(isoLike).replace(' ', 'T'));
if (Number.isNaN(dt.getTime())) return '';
return dt.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
}
function formatOperator(row) {
const imie = (row.otwierajacy_imie || '').trim();
const nazwisko = (row.otwierajacy_nazwisko || '').trim();
return `${imie} ${nazwisko}`.trim();
}
function updateNotifyBanner() {
if (!('Notification' in window)) {
notifyBanner.classList.add('hidden');
return;
}
if (Notification.permission === 'default') {
notifyBanner.classList.remove('hidden');
return;
}
notifyBanner.classList.add('hidden');
}
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
return null;
}
try {
swRegistration = await navigator.serviceWorker.register('sw.js');
await navigator.serviceWorker.ready;
return swRegistration;
} catch (err) {
console.warn('[waiter] SW registration failed', err);
return null;
}
}
async function requestNotifications() {
if (!('Notification' in window)) {
alert('Ta przeglądarka nie obsługuje powiadomień.');
return;
}
const permission = await Notification.requestPermission();
updateNotifyBanner();
if (permission === 'granted') {
await registerServiceWorker();
}
}
function showNotification(row) {
const isWaiter = row.message_type === 'waiter_call';
const title = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
const firstLine = (row.message_text || '').split('\n')[0].trim();
const body = `Stolik ${row.table_id || '?'}` + (firstLine ? `\n${firstLine}` : '');
const payload = {
type: 'notify',
title,
body,
tag: `waiter-${row.id}`,
};
if (swRegistration && swRegistration.active) {
swRegistration.active.postMessage(payload);
return;
}
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(payload);
return;
}
if (Notification.permission === 'granted') {
new Notification(title, {
body,
tag: payload.tag,
renotify: true,
});
}
}
function handleNewRows(rows) {
const fresh = [];
for (const row of rows) {
const id = Number(row.id);
if (!feedInitialized) {
knownIds.add(id);
continue;
}
if (!knownIds.has(id)) {
knownIds.add(id);
fresh.push(row);
}
}
feedInitialized = true;
for (const row of fresh) {
showNotification(row);
}
}
function renderFeed(rows) {
if (!rows.length) {
feedList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
feedList.innerHTML = rows.map((row) => {
const isWaiter = row.message_type === 'waiter_call';
const typeLabel = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
const operator = formatOperator(row);
const operatorHtml = operator
? `<div class="feed-operator">Kelner stolika: <strong>${escapeHtml(operator)}</strong></div>`
: '';
const pending = Number(row.status_kds) === 0;
const cardClass = [
'feed-card',
isWaiter ? 'waiter' : 'bill',
pending ? '' : 'done',
].filter(Boolean).join(' ');
return `
<article class="${cardClass}" data-id="${row.id}">
<div class="feed-head">
<div class="feed-title">${escapeHtml(typeLabel)}</div>
<div class="feed-time">${escapeHtml(formatTime(row.created_at))}</div>
</div>
<div class="feed-table">Stolik ${escapeHtml(row.table_id || '?')}</div>
${operatorHtml}
<div class="feed-msg">${escapeHtml(row.message_text || '')}</div>
<div class="feed-badges">
<span class="badge ${pending ? 'badge-pending' : 'badge-done'}">
${pending ? 'Aktywne w KDS' : 'Obsłużone w KDS'}
</span>
</div>
</article>
`;
}).join('');
}
function updateStats(summary, total) {
statPending.textContent = String(summary?.pending ?? 0);
statWaiter.textContent = String(summary?.waiter_calls ?? 0);
statBill.textContent = String(summary?.bill_requests ?? 0);
statTotal.textContent = String(total ?? 0);
}
function setSyncState(ok, message) {
syncDot.classList.toggle('ok', ok);
syncDot.classList.toggle('err', !ok);
syncLabel.textContent = message;
}
async function pollFeed() {
try {
const response = await fetch(API_URL, { cache: 'no-store' });
const result = await response.json();
if (result.status !== 'success') {
setSyncState(false, 'Błąd API');
return;
}
const payload = JSON.stringify(result.data || []);
const rows = result.data || [];
handleNewRows(rows);
if (payload !== lastPayload) {
renderFeed(rows);
lastPayload = payload;
}
updateStats(result.summary, result.count);
const now = new Date();
setSyncState(true, `Sync ${now.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`);
} catch {
setSyncState(false, 'Brak połączenia');
}
}
function startPolling() {
pollFeed();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollFeed, POLL_MS);
}
enableNotifyBtn?.addEventListener('click', () => {
requestNotifications();
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollFeed();
}
});
(async function init() {
updateNotifyBanner();
if ('Notification' in window && Notification.permission === 'granted') {
await registerServiceWorker();
} else if ('Notification' in window && Notification.permission === 'default') {
notifyBanner.classList.remove('hidden');
}
startPolling();
})();

66
public/waiter/index.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../includes/asset_version.php';
$waiterDir = __DIR__;
$vCss = publicAssetVersion($waiterDir, 'app.css');
$vJs = publicAssetVersion($waiterDir, 'app.js');
?><!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Kelner wezwania</title>
<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) ?>">
</head>
<body>
<header class="top-bar">
<div>
<h1>Panel kelnera</h1>
<p class="subtitle">Wezwania i prośby o rachunek · dziś</p>
</div>
<div class="sync-pill" id="syncPill">
<span class="sync-dot" id="syncDot"></span>
<span id="syncLabel">Łączenie…</span>
</div>
</header>
<section class="notify-banner hidden" id="notifyBanner">
<div>
<strong>Powiadomienia wyłączone</strong>
<p>Włącz je, aby dostać alert przy nowym wezwaniu, nawet gdy ekran jest zablokowany w tle.</p>
</div>
<button type="button" class="btn btn-primary" id="enableNotifyBtn">Włącz powiadomienia</button>
</section>
<section class="stats-grid" id="statsGrid">
<div class="stat-card">
<span class="stat-label">Aktywne</span>
<span class="stat-value" id="statPending"></span>
</div>
<div class="stat-card">
<span class="stat-label">Kelner</span>
<span class="stat-value" id="statWaiter"></span>
</div>
<div class="stat-card">
<span class="stat-label">Rachunek</span>
<span class="stat-value" id="statBill"></span>
</div>
<div class="stat-card">
<span class="stat-label">Razem dziś</span>
<span class="stat-value" id="statTotal"></span>
</div>
</section>
<main id="feedList" class="feed-list"></main>
<div class="empty-state hidden" id="emptyState">
<div class="empty-icon">🛎️</div>
<p>Brak wezwań na dziś. Czekam na nowe…</p>
</div>
<script src="app.js?v=<?= assetVersionAttr($vJs) ?>"></script>
</body>
</html>

23
public/waiter/sw.js Normal file
View File

@@ -0,0 +1,23 @@
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.type !== 'notify') {
return;
}
event.waitUntil(
self.registration.showNotification(data.title || 'Panel kelnera', {
body: data.body || '',
tag: data.tag || 'waiter-alert',
renotify: true,
requireInteraction: true,
})
);
});