Naprawa lokalizacji - odczytu
This commit is contained in:
5
.htaccess
Normal file
5
.htaccess
Normal 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
22
api/geo_bypass.php
Normal 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
94
api/request_ip.php
Normal 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
77
api/waiter_feed.php
Normal 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
5
app/.htaccess
Normal 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>
|
||||||
@@ -74,6 +74,9 @@
|
|||||||
<a href="public/staff/kds.php" class="dev-link kds">
|
<a href="public/staff/kds.php" class="dev-link kds">
|
||||||
🍳 Ekran KDS (Kuchnia)
|
🍳 Ekran KDS (Kuchnia)
|
||||||
</a>
|
</a>
|
||||||
|
<a href="public/waiter/index.php" class="dev-link client">
|
||||||
|
🛎️ Panel Kelnera (wezwania)
|
||||||
|
</a>
|
||||||
<a href="public/app.html" class="dev-link client">
|
<a href="public/app.html" class="dev-link client">
|
||||||
📱 Aplikacja dla Gościa (Poprosi o Hash)
|
📱 Aplikacja dla Gościa (Poprosi o Hash)
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
17
public/.htaccess
Normal file
17
public/.htaccess
Normal 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>
|
||||||
356
public/app.html
356
public/app.html
@@ -1,340 +1,24 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="pl">
|
<html lang="pl">
|
||||||
|
|
||||||
<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">
|
||||||
<title>Karczma Biesiada – Twoje Zamówienie</title>
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<meta http-equiv="Expires" content="0">
|
||||||
<link
|
<title>Karczma Biesiada – przekierowanie</title>
|
||||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap"
|
<script>
|
||||||
rel="stylesheet">
|
(function () {
|
||||||
<link rel="stylesheet" href="assets/css/app.css">
|
var target = "app.php" + window.location.search + window.location.hash;
|
||||||
</head>
|
window.location.replace(target);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<p style="font-family: sans-serif; text-align: center; margin-top: 2rem; color: #333;">
|
||||||
<div id="loadingScreen">
|
Przekierowanie do aplikacji…
|
||||||
<div class="loader-icon"></div>
|
<br><br>
|
||||||
<div class="loader-text">
|
<a href="app.php">Kliknij tutaj, jeśli nic się nie dzieje</a>
|
||||||
<h2>Karczma Biesiada</h2>
|
</p>
|
||||||
<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;">
|
|
||||||
© 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;">×</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()">×</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>
|
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
|
|||||||
378
public/app.php
Normal file
378
public/app.php
Normal 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ą Wi‑Fi 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;">
|
||||||
|
© 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;">×</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()">×</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>
|
||||||
@@ -71,33 +71,191 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 24px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: opacity 0.5s ease, visibility 0.5s;
|
transition: opacity 0.5s ease, visibility 0.5s;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geo-icon {
|
.geo-icon {
|
||||||
font-size: 80px;
|
font-size: 64px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
animation: bounce 2s infinite;
|
animation: bounce 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%, 100% { transform: translateY(0); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-15px); }
|
50% { transform: translateY(-10px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.geo-text h2 {
|
.geo-title {
|
||||||
font-family: 'Playfair Display', serif;
|
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);
|
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);
|
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;
|
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 --- */
|
/* --- MAIN LAYOUT --- */
|
||||||
@@ -907,6 +1065,10 @@ header {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-only-banner.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-only-banner-btn {
|
.menu-only-banner-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid rgba(226, 176, 126, 0.45);
|
border: 1px solid rgba(226, 176, 126, 0.45);
|
||||||
@@ -944,6 +1106,10 @@ header {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#menuOnlyBanner.is-hidden + .menu-search-container {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#menuSearchInput {
|
#menuSearchInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ window.kitchenAnimations = [
|
|||||||
];
|
];
|
||||||
window.selectedAnimationHtml = null;
|
window.selectedAnimationHtml = null;
|
||||||
|
|
||||||
|
const MENU_ASSET_VERSION =
|
||||||
|
window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
let hashParam = (params.get("h") || "").trim();
|
let hashParam = (params.get("h") || "").trim();
|
||||||
const isStaffPreview = params.get("preview") === "staff";
|
const isStaffPreview = params.get("preview") === "staff";
|
||||||
@@ -454,7 +457,10 @@ function updateNavAccessState() {
|
|||||||
if (el) el.classList.toggle("nav-locked", locked);
|
if (el) el.classList.toggle("nav-locked", locked);
|
||||||
});
|
});
|
||||||
const banner = document.getElementById("menuOnlyBanner");
|
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() {
|
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 = {
|
const GEO_GATE_LABELS = {
|
||||||
status: "status zamówienia",
|
status: "status zamówienia",
|
||||||
waiter: "wezwanie kelnera",
|
waiter: "wezwanie kelnera",
|
||||||
bill: "prośbę o rachunek",
|
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) {
|
function configureGeoSecondaryButton(mode) {
|
||||||
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
|
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
|
||||||
if (!menuOnlyBtn) return;
|
if (!menuOnlyBtn) return;
|
||||||
|
|
||||||
|
geoMenuButtonMode = mode;
|
||||||
|
const mainEl = menuOnlyBtn.querySelector(".geo-btn-main");
|
||||||
|
const subEl = menuOnlyBtn.querySelector(".geo-btn-sub");
|
||||||
|
|
||||||
if (mode === "back_to_menu") {
|
if (mode === "back_to_menu") {
|
||||||
menuOnlyBtn.style.display = "";
|
menuOnlyBtn.style.display = "";
|
||||||
menuOnlyBtn.textContent = "Wróć do menu";
|
if (mainEl) mainEl.textContent = "Wróć do menu";
|
||||||
menuOnlyBtn.onclick = () => {
|
if (subEl) {
|
||||||
document.getElementById("geoScreen")?.classList.add("hidden");
|
subEl.textContent = "";
|
||||||
};
|
subEl.style.display = "none";
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
menuOnlyBtn.style.display = "";
|
menuOnlyBtn.style.display = "";
|
||||||
menuOnlyBtn.textContent = "Przeglądaj menu bez lokalizacji";
|
if (mainEl) mainEl.textContent = "Przejdź do menu";
|
||||||
menuOnlyBtn.onclick = () => enterMenuOnlyMode();
|
if (subEl) {
|
||||||
|
subEl.textContent = "bez lokalizacji";
|
||||||
|
subEl.style.display = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGeoGateForAction(action) {
|
function showGeoGateForAction(action) {
|
||||||
const geoScreen = document.getElementById("geoScreen");
|
const geoScreen = document.getElementById("geoScreen");
|
||||||
const loadingScreen = document.getElementById("loadingScreen");
|
const loadingScreen = document.getElementById("loadingScreen");
|
||||||
const geoMsg = document.getElementById("geoMsg");
|
|
||||||
const geoActionBtn = document.getElementById("geoActionBtn");
|
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ę funkcję";
|
const feature = GEO_GATE_LABELS[action] || "tę funkcję";
|
||||||
if (geoMsg) {
|
setGeoLead(`Menu masz już otwarte. Aby skorzystać z <b>${feature}</b>, potwierdź krótko, że jesteś w restauracji.`);
|
||||||
geoMsg.innerHTML = `Aby skorzystać z <b>${feature}</b>, potwierdź, że jesteś w restauracji.<br><br>Prosimy o zgodę na dostęp do lokalizacji.`;
|
setGeoStatus("");
|
||||||
}
|
hideGeoInstructions();
|
||||||
if (geoActionBtn) {
|
if (geoActionBtn) {
|
||||||
geoActionBtn.disabled = false;
|
setGeoActionBusy(false);
|
||||||
geoActionBtn.textContent = "Sprawdź lokalizację";
|
setGeoActionLabel("Sprawdź lokalizację");
|
||||||
}
|
}
|
||||||
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
|
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
|
||||||
}
|
}
|
||||||
@@ -1322,7 +1438,7 @@ window.fetchGUS = async function () {
|
|||||||
// --- DYNAMIC MENU LOADING ---
|
// --- DYNAMIC MENU LOADING ---
|
||||||
async function loadMenu() {
|
async function loadMenu() {
|
||||||
try {
|
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');
|
if (!response.ok) throw new Error('Nie udało się załadować menu');
|
||||||
const menuData = await response.json();
|
const menuData = await response.json();
|
||||||
window.menuDataRaw = menuData;
|
window.menuDataRaw = menuData;
|
||||||
@@ -1467,9 +1583,13 @@ window.confirmInvoice = async function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- GEOLOCATION LOGIC ---
|
// --- GEOLOCATION LOGIC ---
|
||||||
const RESTAURANT_LAT = 50.5624963;
|
// Dwa punkty odniesienia: OSM (adres budynku) i pin Google Maps (z nim porównują goście w Maps).
|
||||||
const RESTAURANT_LNG = 22.0608059;
|
const RESTAURANT_LOCATIONS = [
|
||||||
const MAX_DISTANCE_METERS = 200;
|
{ 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) {
|
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||||
const R = 6371e3;
|
const R = 6371e3;
|
||||||
@@ -1485,6 +1605,76 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||||||
return R * c;
|
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() {
|
function startApp() {
|
||||||
appAccessLevel = "full";
|
appAccessLevel = "full";
|
||||||
updateNavAccessState();
|
updateNavAccessState();
|
||||||
@@ -1512,28 +1702,30 @@ function startApp() {
|
|||||||
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 geoMsg = document.getElementById("geoMsg");
|
|
||||||
const geoActionBtn = document.getElementById("geoActionBtn");
|
const geoActionBtn = document.getElementById("geoActionBtn");
|
||||||
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
|
|
||||||
|
|
||||||
loadingScreen.classList.add("hidden");
|
loadingScreen.classList.add("hidden");
|
||||||
geoScreen.classList.remove("hidden");
|
geoScreen.classList.remove("hidden");
|
||||||
|
|
||||||
if (geoMsg) {
|
setGeoLead(GEO_DEFAULT_LEAD);
|
||||||
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.`;
|
setGeoStatus("");
|
||||||
}
|
hideGeoInstructions();
|
||||||
|
|
||||||
if (geoActionBtn) {
|
if (geoActionBtn) {
|
||||||
geoActionBtn.disabled = false;
|
setGeoActionBusy(false);
|
||||||
geoActionBtn.textContent = "Udziel zgody / Sprawdź";
|
setGeoActionLabel("Zgoda, sprawdź lokalizację");
|
||||||
}
|
|
||||||
if (menuOnlyBtn) {
|
|
||||||
configureGeoSecondaryButton("menu_only");
|
|
||||||
}
|
}
|
||||||
|
configureGeoSecondaryButton("menu_only");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIOSDevice() {
|
function showGeoPermissionBlockedState() {
|
||||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
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() {
|
function shouldBypassGeolocationHost() {
|
||||||
@@ -1541,135 +1733,189 @@ function shouldBypassGeolocationHost() {
|
|||||||
return bypassHosts.includes(window.location.hostname);
|
return bypassHosts.includes(window.location.hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initGeolocation = function () {
|
async function checkGeoBypassByClientIp() {
|
||||||
if (shouldBypassGeolocationHost()) {
|
try {
|
||||||
console.warn("Bypassing geolocation for trusted host.");
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||||||
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "trusted_host" });
|
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();
|
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;
|
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 geoScreen = document.getElementById("geoScreen");
|
||||||
const loadingScreen = document.getElementById("loadingScreen");
|
const loadingScreen = document.getElementById("loadingScreen");
|
||||||
const geoMsg = document.getElementById("geoMsg");
|
|
||||||
const geoActionBtn = document.getElementById("geoActionBtn");
|
const geoActionBtn = document.getElementById("geoActionBtn");
|
||||||
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
|
|
||||||
|
|
||||||
const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84'];
|
const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84'];
|
||||||
if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) {
|
if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) {
|
||||||
console.warn("Bypassing geolocation on local HTTP environment.");
|
console.warn("Bypassing geolocation on local HTTP environment.");
|
||||||
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "local_http" });
|
bypassGeolocation("local_http", { host: window.location.hostname });
|
||||||
unlockFullApp();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.isSecureContext) {
|
loadingScreen?.classList.add("hidden");
|
||||||
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Ta strona nie jest uruchomiona w bezpiecznym trybie HTTPS.</b><br><br>
|
geoScreen?.classList.remove("hidden");
|
||||||
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");
|
|
||||||
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
|
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
if (!window.isSecureContext) {
|
||||||
geoMsg.innerHTML = "Twoja przeglądarka nie wspiera geolokalizacji. Aplikacja wymaga nowszej przeglądarki.";
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
geoMsg.innerHTML = "Sprawdzamy Twoją lokalizację...";
|
if (!navigator.geolocation) {
|
||||||
trackEvent("geo_check_started");
|
setGeoLead(GEO_DEFAULT_LEAD);
|
||||||
if (geoActionBtn) {
|
setGeoStatus("Twoja przeglądarka nie wspiera geolokalizacji.", { error: true });
|
||||||
geoActionBtn.disabled = true;
|
hideGeoInstructions();
|
||||||
geoActionBtn.textContent = "Sprawdzanie...";
|
setGeoActionBusy(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
setGeoLead(GEO_DEFAULT_LEAD);
|
||||||
(position) => {
|
setGeoStatus("Sprawdzamy Twoją lokalizację…", { info: true });
|
||||||
const dist = haversineDistance(
|
hideGeoInstructions();
|
||||||
RESTAURANT_LAT, RESTAURANT_LNG,
|
setGeoActionBusy(true);
|
||||||
position.coords.latitude, position.coords.longitude
|
|
||||||
);
|
|
||||||
|
|
||||||
const accuracy = position.coords.accuracy;
|
const permissionState = await queryGeolocationPermissionState();
|
||||||
console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`);
|
if (permissionState === "denied") {
|
||||||
|
showGeoPermissionBlockedState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dist <= MAX_DISTANCE_METERS) {
|
trackEvent("geo_check_started");
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Przeglądarka zablokowała dostęp do lokalizacji.</b><br><br>
|
try {
|
||||||
Bez tego nie możemy zweryfikować, czy jesteś w restauracji.<br><br>
|
const result = await requestRestaurantGeolocation();
|
||||||
${instructions}`;
|
const dist = result.dist;
|
||||||
} else {
|
const accuracy = result.accuracy;
|
||||||
geoMsg.innerHTML = "Nie udało się pobrać lokalizacji. Sprawdź zasięg lub włącz GPS i spróbuj ponownie.";
|
|
||||||
}
|
console.log(
|
||||||
},
|
`[GEO] Lat: ${result.position.coords.latitude}, Lng: ${result.position.coords.longitude}, ` +
|
||||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
`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()) {
|
if (shouldBypassGeolocationHost()) {
|
||||||
startApp();
|
bypassGeolocation("trusted_host", { host: window.location.hostname });
|
||||||
} else if (isIOSDevice()) {
|
|
||||||
showGeoConsentScreen();
|
|
||||||
} else {
|
} else {
|
||||||
initGeolocation();
|
bootstrapGeolocation();
|
||||||
}
|
}
|
||||||
23
public/includes/asset_version.php
Normal file
23
public/includes/asset_version.php
Normal 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
291
public/waiter/app.css
Normal 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
249
public/waiter/app.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
66
public/waiter/index.php
Normal 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
23
public/waiter/sw.js
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user