diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..313fc43 --- /dev/null +++ b/.htaccess @@ -0,0 +1,5 @@ +# Gdy ten plik leży w public_html/biesiada.menu/ (nad katalogiem app/) + + RewriteEngine On + RewriteRule ^app/public/app\.html$ app/public/app.php [L,QSA] + diff --git a/api/geo_bypass.php b/api/geo_bypass.php new file mode 100644 index 0000000..f2132eb --- /dev/null +++ b/api/geo_bypass.php @@ -0,0 +1,22 @@ + '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); diff --git a/api/request_ip.php b/api/request_ip.php new file mode 100644 index 0000000..d76f5a6 --- /dev/null +++ b/api/request_ip.php @@ -0,0 +1,94 @@ + 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; +} diff --git a/api/waiter_feed.php b/api/waiter_feed.php new file mode 100644 index 0000000..f2b4650 --- /dev/null +++ b/api/waiter_feed.php @@ -0,0 +1,77 @@ + '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); +} diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..cbd8e41 --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,5 @@ +# Gdy document root wskazuje na katalog app/ (URL: /public/app.html) + + RewriteEngine On + RewriteRule ^public/app\.html$ public/app.php [L,QSA] + diff --git a/index.php b/index.php index 7b07512..87e4de1 100644 --- a/index.php +++ b/index.php @@ -74,6 +74,9 @@ 🍳 Ekran KDS (Kuchnia) + + 🛎️ Panel Kelnera (wezwania) + 📱 Aplikacja dla Gościa (Poprosi o Hash) diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..54d65a7 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,17 @@ +# Aplikacja gościa: świeży HTML/PHP (w app.php dynamiczne ?v= z filemtime) + + + Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" + Header set Pragma "no-cache" + Header set Expires "0" + + + +# 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) + + RewriteEngine On + RewriteRule ^app\.html$ app.php [L,QSA] + diff --git a/public/app.html b/public/app.html index 000f363..ac2b0cf 100644 --- a/public/app.html +++ b/public/app.html @@ -1,340 +1,24 @@ - + - - - - Karczma Biesiada – Twoje Zamówienie - - - - - - + + + + + + Karczma Biesiada – przekierowanie + + - -
-
-
-

Karczma Biesiada

-
Łączenie z kuchnią...
-
-
- - - -
-
-

Karczma Biesiada

-
Wybierz stolik
-
- - -
-
- -
-
-
- Aktualny status -
Oczekiwanie...
-
-
-
- -
-
-
- -
- Sprawdzamy co pysznego się przygotowuje... -
-
- -
-

Twoje zamówione dania

- -
-
- - - - -
- - - -
- - -
- - - - - - - - - - - - - - - - -
- Wysłano! -
- - +

+ Przekierowanie do aplikacji… +

+ Kliknij tutaj, jeśli nic się nie dzieje +

- - \ No newline at end of file + diff --git a/public/app.php b/public/app.php new file mode 100644 index 0000000..1d075fe --- /dev/null +++ b/public/app.php @@ -0,0 +1,378 @@ + + + + + + + + + + Karczma Biesiada – Twoje Zamówienie + + + + + + + + +
+
+
+

Karczma Biesiada

+
Łączenie z kuchnią...
+
+
+ + + +
+
+

Karczma Biesiada

+
Wybierz stolik
+
+ + +
+
+ +
+
+
+ Aktualny status +
Oczekiwanie...
+
+
+
+ +
+
+
+ +
+ Sprawdzamy co pysznego się przygotowuje... +
+
+ +
+

Twoje zamówione dania

+ +
+
+ + + + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + +
+ Wysłano! +
+ + + + + + \ No newline at end of file diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 36cef48..cac024a 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -71,33 +71,191 @@ body { flex-direction: column; align-items: center; justify-content: center; - padding: 40px; + padding: 24px 20px; text-align: center; transition: opacity 0.5s ease, visibility 0.5s; + overflow-y: auto; +} + +.geo-shell { + width: 100%; + max-width: 400px; } .geo-icon { - font-size: 80px; - margin-bottom: 16px; + font-size: 64px; + margin-bottom: 12px; animation: bounce 2s infinite; } @keyframes bounce { 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-15px); } + 50% { transform: translateY(-10px); } } -.geo-text h2 { +.geo-title { font-family: 'Playfair Display', serif; - margin: 0 0 12px; + margin: 0 0 10px; + color: var(--primary); + font-size: 1.65rem; + line-height: 1.2; +} + +.geo-lead { + color: var(--text-muted); + font-size: 15px; + line-height: 1.45; + margin: 0; +} + +.geo-status { + color: var(--text-muted); + font-size: 14px; + line-height: 1.45; + margin: 14px 0 0; + min-height: 0; +} + +.geo-status:empty { + display: none; +} + +.geo-status.is-error { + color: #ff8a8a; + font-weight: 600; +} + +.geo-status.is-info { color: var(--primary); } -.geo-msg { +.geo-actions { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 10px; + margin-top: 20px; + width: 100%; +} + +.geo-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-height: 76px; + padding: 12px 10px; + border-radius: 14px; + cursor: pointer; + font-family: inherit; + line-height: 1.25; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.geo-btn:active { + transform: scale(0.98); +} + +.geo-btn-main { + font-size: 14px; + font-weight: 700; +} + +.geo-btn-sub { + font-size: 11px; + font-weight: 500; + opacity: 0.85; +} + +.geo-btn-menu { + background: rgba(255, 255, 255, 0.03); + border: 1.5px solid rgba(226, 176, 126, 0.25); color: var(--text-muted); - font-size: 15px; +} + +.geo-btn-menu .geo-btn-main { + color: var(--text-main); +} + +.geo-btn-locate { + border: none; + box-shadow: 0 4px 18px rgba(226, 176, 126, 0.28); +} + +.geo-btn-locate .geo-btn-main { + font-size: 13px; + line-height: 1.3; +} + +.geo-btn-locate:disabled { + opacity: 0.65; + cursor: wait; + transform: none; +} + +.geo-instructions { + margin-top: 16px; + padding: 14px 16px; + background: var(--surface-light); + border: 1px solid rgba(226, 176, 126, 0.2); + border-radius: 12px; + font-size: 13px; + line-height: 1.55; + color: #d1d5db; + text-align: left; +} + +.geo-instructions.hidden { + display: none; +} + +.geo-wifi-callout { + margin: 20px 0 0; + padding: 16px 18px; + background: rgba(226, 176, 126, 0.1); + border: 1.5px solid rgba(226, 176, 126, 0.35); + border-radius: 14px; + font-size: 14px; line-height: 1.5; - margin-bottom: 16px; + color: #e8e8ea; + text-align: left; +} + +.geo-wifi-callout-title { + margin: 0 0 10px; + font-size: 16px; + font-weight: 700; + color: var(--primary); +} + +.geo-wifi-callout p { + margin: 0 0 8px; +} + +.geo-wifi-network, +.geo-wifi-password { + font-size: 15px; + color: var(--text-main); +} + +.geo-wifi-list { + margin: 8px 0 0; + padding-left: 20px; + color: #d1d5db; +} + +.geo-wifi-list li { + margin-bottom: 4px; +} + +.geo-wifi-list li:last-child { + margin-bottom: 0; +} + +@media (max-width: 360px) { + .geo-actions { + grid-template-columns: 1fr; + } } /* --- MAIN LAYOUT --- */ @@ -907,6 +1065,10 @@ header { line-height: 1.45; } +.menu-only-banner.is-hidden { + display: none !important; +} + .menu-only-banner-btn { flex-shrink: 0; border: 1px solid rgba(226, 176, 126, 0.45); @@ -944,6 +1106,10 @@ header { margin-bottom: 10px; } +#menuOnlyBanner.is-hidden + .menu-search-container { + padding-top: 0; +} + #menuSearchInput { width: 100%; background: var(--surface); diff --git a/public/assets/js/app.js b/public/assets/js/app.js index b3fc42c..fa6b06e 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -7,6 +7,9 @@ window.kitchenAnimations = [ ]; window.selectedAnimationHtml = null; +const MENU_ASSET_VERSION = + window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1"; + const params = new URLSearchParams(location.search); let hashParam = (params.get("h") || "").trim(); const isStaffPreview = params.get("preview") === "staff"; @@ -454,7 +457,10 @@ function updateNavAccessState() { if (el) el.classList.toggle("nav-locked", locked); }); const banner = document.getElementById("menuOnlyBanner"); - if (banner) banner.classList.toggle("hidden", appAccessLevel !== "menu"); + if (banner) { + banner.classList.remove("hidden"); + banner.classList.toggle("is-hidden", appAccessLevel !== "menu"); + } } function showBottomNav() { @@ -479,46 +485,156 @@ async function resolveTableLabel() { } } +function isIOSDevice() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +} + const GEO_GATE_LABELS = { status: "status zamówienia", waiter: "wezwanie kelnera", bill: "prośbę o rachunek", }; +const GEO_DEFAULT_LEAD = + "Przeglądaj menu od razu — albo potwierdź, że jesteś u nas, aby wezwać kelnera, śledzić zamówienie i poprosić o rachunek."; + +function setGeoLead(html) { + const el = document.getElementById("geoLead"); + if (el) el.innerHTML = html; +} + +function setGeoStatus(html, { error = false, info = false } = {}) { + const el = document.getElementById("geoMsg"); + if (!el) return; + el.innerHTML = html || ""; + el.classList.toggle("is-error", error); + el.classList.toggle("is-info", info); +} + +function showGeoInstructions(html) { + const el = document.getElementById("geoInstructions"); + if (!el) return; + el.innerHTML = html || ""; + el.classList.toggle("hidden", !html); +} + +function hideGeoInstructions() { + showGeoInstructions(""); +} + +function setGeoActionBusy(busy) { + const btn = document.getElementById("geoActionBtn"); + if (!btn) return; + btn.disabled = false; + btn.setAttribute("aria-busy", busy ? "true" : "false"); + if (busy) { + setGeoActionLabel("Sprawdzanie…"); + } +} + +function isGeoPermissionDenied(error) { + return Number(error?.code) === 1; +} + +function setGeoActionLabel(text) { + const btn = document.getElementById("geoActionBtn"); + if (!btn) return; + const main = btn.querySelector(".geo-btn-main"); + if (main) main.textContent = text; + else btn.textContent = text; +} + +function getGeoPermissionInstructions() { + if (isIOSDevice()) { + return `iPhone (Safari):
+ 1. Kliknij aA po lewej stronie paska adresu.
+ 2. Wybierz Ustawienia witryny.
+ 3. Ustaw Położenie na „Zapytaj” lub „Pozwalaj”.
+ 4. Odśwież stronę.

+ Lokalizacja działa tylko przez bezpieczne https://.`; + } + + return `Android / Chrome:
+ 1. Kliknij ikonę kłódki obok adresu strony.
+ 2. W Uprawnieniach zmień Lokalizację na „Zezwalaj”.
+ 3. Odśwież stronę.`; +} + +let geoMenuButtonMode = "menu_only"; + +window.handleGeoMenuClick = function () { + if (geoMenuButtonMode === "back_to_menu") { + document.getElementById("geoScreen")?.classList.add("hidden"); + return; + } + enterMenuOnlyMode(); +}; + +window.retryGeolocation = function () { + if (shouldBypassGeolocationHost()) { + bypassGeolocation("trusted_host", { host: window.location.hostname }); + return; + } + + initGeolocationAfterBypassChecks({ userInitiated: true }).catch((err) => { + console.error("[GEO] retry failed", err); + showGeoPermissionBlockedState(); + }); +}; + +function bindGeoScreenButtons() { + document.getElementById("geoMenuOnlyBtn")?.addEventListener("click", (event) => { + event.preventDefault(); + handleGeoMenuClick(); + }); + + document.getElementById("geoActionBtn")?.addEventListener("click", (event) => { + event.preventDefault(); + retryGeolocation(); + }); +} + function configureGeoSecondaryButton(mode) { const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn"); if (!menuOnlyBtn) return; + geoMenuButtonMode = mode; + const mainEl = menuOnlyBtn.querySelector(".geo-btn-main"); + const subEl = menuOnlyBtn.querySelector(".geo-btn-sub"); + if (mode === "back_to_menu") { menuOnlyBtn.style.display = ""; - menuOnlyBtn.textContent = "Wróć do menu"; - menuOnlyBtn.onclick = () => { - document.getElementById("geoScreen")?.classList.add("hidden"); - }; + if (mainEl) mainEl.textContent = "Wróć do menu"; + if (subEl) { + subEl.textContent = ""; + subEl.style.display = "none"; + } return; } menuOnlyBtn.style.display = ""; - menuOnlyBtn.textContent = "Przeglądaj menu bez lokalizacji"; - menuOnlyBtn.onclick = () => enterMenuOnlyMode(); + if (mainEl) mainEl.textContent = "Przejdź do menu"; + if (subEl) { + subEl.textContent = "bez lokalizacji"; + subEl.style.display = ""; + } } function showGeoGateForAction(action) { const geoScreen = document.getElementById("geoScreen"); const loadingScreen = document.getElementById("loadingScreen"); - const geoMsg = document.getElementById("geoMsg"); const geoActionBtn = document.getElementById("geoActionBtn"); if (loadingScreen) loadingScreen.classList.add("hidden"); if (geoScreen) geoScreen.classList.remove("hidden"); const feature = GEO_GATE_LABELS[action] || "tę funkcję"; - if (geoMsg) { - geoMsg.innerHTML = `Aby skorzystać z ${feature}, potwierdź, że jesteś w restauracji.

Prosimy o zgodę na dostęp do lokalizacji.`; - } + setGeoLead(`Menu masz już otwarte. Aby skorzystać z ${feature}, potwierdź krótko, że jesteś w restauracji.`); + setGeoStatus(""); + hideGeoInstructions(); if (geoActionBtn) { - geoActionBtn.disabled = false; - geoActionBtn.textContent = "Sprawdź lokalizację"; + setGeoActionBusy(false); + setGeoActionLabel("Sprawdź lokalizację"); } configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); } @@ -1322,7 +1438,7 @@ window.fetchGUS = async function () { // --- DYNAMIC MENU LOADING --- async function loadMenu() { try { - const response = await fetch('menu.json'); + const response = await fetch(`menu.json?v=${encodeURIComponent(MENU_ASSET_VERSION)}`); if (!response.ok) throw new Error('Nie udało się załadować menu'); const menuData = await response.json(); window.menuDataRaw = menuData; @@ -1467,9 +1583,13 @@ window.confirmInvoice = async function () { }; // --- GEOLOCATION LOGIC --- -const RESTAURANT_LAT = 50.5624963; -const RESTAURANT_LNG = 22.0608059; -const MAX_DISTANCE_METERS = 200; +// Dwa punkty odniesienia: OSM (adres budynku) i pin Google Maps (z nim porównują goście w Maps). +const RESTAURANT_LOCATIONS = [ + { lat: 50.5622609, lng: 22.0606303, source: "osm" }, + { lat: 50.567953, lng: 22.061045, source: "google_maps" }, +]; +const MAX_DISTANCE_METERS = 300; +const MAX_ACCURACY_BONUS = 150; function haversineDistance(lat1, lon1, lat2, lon2) { const R = 6371e3; @@ -1485,6 +1605,76 @@ function haversineDistance(lat1, lon1, lat2, lon2) { return R * c; } +function distanceToRestaurant(lat, lng) { + return Math.min( + ...RESTAURANT_LOCATIONS.map(({ lat: rLat, lng: rLng }) => + haversineDistance(rLat, rLng, lat, lng) + ) + ); +} + +function isInsideRestaurantGeofence(distanceMeters, accuracyMeters) { + const accuracyBonus = Math.min(Math.max(Number(accuracyMeters) || 0, 0), MAX_ACCURACY_BONUS); + return distanceMeters <= MAX_DISTANCE_METERS + accuracyBonus; +} + +function requestRestaurantGeolocation() { + const geoOptions = { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 0, + }; + + return new Promise((resolve, reject) => { + let bestSample = null; + let watchId = null; + let settled = false; + + const finish = (result) => { + if (settled) return; + settled = true; + if (watchId != null) navigator.geolocation.clearWatch(watchId); + clearTimeout(timeoutId); + resolve(result); + }; + + const fail = (error) => { + if (settled) return; + settled = true; + if (watchId != null) navigator.geolocation.clearWatch(watchId); + clearTimeout(timeoutId); + reject(error); + }; + + const handlePosition = (position) => { + const dist = distanceToRestaurant(position.coords.latitude, position.coords.longitude); + const accuracy = position.coords.accuracy; + const sample = { position, dist, accuracy }; + + if (!bestSample || dist < bestSample.dist) { + bestSample = sample; + } + + if (isInsideRestaurantGeofence(dist, accuracy)) { + finish({ passed: true, ...sample }); + } + }; + + const timeoutId = setTimeout(() => { + if (bestSample) { + finish({ + passed: isInsideRestaurantGeofence(bestSample.dist, bestSample.accuracy), + ...bestSample, + }); + return; + } + fail({ code: 3, message: "Geolocation timeout" }); + }, 12000); + + watchId = navigator.geolocation.watchPosition(handlePosition, fail, geoOptions); + }); +} + function startApp() { appAccessLevel = "full"; updateNavAccessState(); @@ -1512,28 +1702,30 @@ function startApp() { function showGeoConsentScreen() { const geoScreen = document.getElementById("geoScreen"); const loadingScreen = document.getElementById("loadingScreen"); - const geoMsg = document.getElementById("geoMsg"); const geoActionBtn = document.getElementById("geoActionBtn"); - const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn"); loadingScreen.classList.add("hidden"); geoScreen.classList.remove("hidden"); - if (geoMsg) { - geoMsg.innerHTML = `Aby zapewnić bezpieczeństwo Twojego zamówienia, musimy upewnić się, że znajdujesz się na terenie restauracji.

Prosimy o udzielenie zgody na dostęp do lokalizacji w przeglądarce.`; - } + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus(""); + hideGeoInstructions(); if (geoActionBtn) { - geoActionBtn.disabled = false; - geoActionBtn.textContent = "Udziel zgody / Sprawdź"; - } - if (menuOnlyBtn) { - configureGeoSecondaryButton("menu_only"); + setGeoActionBusy(false); + setGeoActionLabel("Zgoda, sprawdź lokalizację"); } + configureGeoSecondaryButton("menu_only"); } -function isIOSDevice() { - return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +function showGeoPermissionBlockedState() { + setGeoStatus("Przeglądarka zablokowała dostęp do lokalizacji.", { error: true }); + showGeoInstructions( + `${getGeoPermissionInstructions()}

Po zmianie ustawień odśwież stronę, a potem kliknij „Spróbuj ponownie”.` + ); + setGeoActionBusy(false); + setGeoActionLabel("Spróbuj ponownie"); + configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); } function shouldBypassGeolocationHost() { @@ -1541,135 +1733,189 @@ function shouldBypassGeolocationHost() { return bypassHosts.includes(window.location.hostname); } -window.initGeolocation = function () { - if (shouldBypassGeolocationHost()) { - console.warn("Bypassing geolocation for trusted host."); - trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "trusted_host" }); +async function checkGeoBypassByClientIp() { + try { + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), 4000) + : null; + + const res = await fetch("../api/geo_bypass.php", { + credentials: "same-origin", + cache: "no-store", + signal: controller?.signal, + }); + + if (timeoutId) clearTimeout(timeoutId); + + const data = await res.json(); + return data.status === "success" && data.bypassGeo === true; + } catch { + return false; + } +} + +function bypassGeolocation(reason, extra = {}) { + trackEvent('geo_bypass_host', { reason, ...extra }); + if (appAccessLevel === 'menu') { unlockFullApp(); + } else { + startApp(); + } +} + +async function queryGeolocationPermissionState() { + if (!navigator.permissions?.query) { + return "unknown"; + } + + try { + const status = await navigator.permissions.query({ name: "geolocation" }); + return status.state; + } catch { + return "unknown"; + } +} + +async function bootstrapGeolocation() { + if (shouldBypassGeolocationHost()) { + bypassGeolocation('trusted_host', { host: window.location.hostname }); return; } + if (await checkGeoBypassByClientIp()) { + bypassGeolocation('trusted_ip'); + return; + } + + showGeoConsentScreen(); +} + +window.initGeolocation = function () { + if (shouldBypassGeolocationHost()) { + console.warn("Bypassing geolocation for trusted host."); + bypassGeolocation("trusted_host", { host: window.location.hostname }); + return; + } + + checkGeoBypassByClientIp().then((bypassByIp) => { + if (bypassByIp) { + console.warn("Bypassing geolocation for trusted client IP."); + bypassGeolocation("trusted_ip"); + return; + } + initGeolocationAfterBypassChecks(); + }); +}; + +async function initGeolocationAfterBypassChecks(options = {}) { const geoScreen = document.getElementById("geoScreen"); const loadingScreen = document.getElementById("loadingScreen"); - const geoMsg = document.getElementById("geoMsg"); const geoActionBtn = document.getElementById("geoActionBtn"); - const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn"); const bypassHosts = ['localhost', '127.0.0.1', '192.168.20.84']; if (window.location.protocol === 'http:' && bypassHosts.includes(window.location.hostname)) { console.warn("Bypassing geolocation on local HTTP environment."); - trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "local_http" }); - unlockFullApp(); + bypassGeolocation("local_http", { host: window.location.hostname }); return; } - if (!window.isSecureContext) { - geoMsg.innerHTML = `Ta strona nie jest uruchomiona w bezpiecznym trybie HTTPS.

- Przeglądarki mobilne blokują geolokalizację bez pytania, jeśli adres nie zaczyna się od https://.

- Otwórz aplikację przez HTTPS i spróbuj ponownie.

- Masz problem z lokalizacją? Połącz się z HotSpot Karczmy, a wtedy wejdziesz do aplikacji bez geolokalizacji.
- Hasło: karczmabiesiada`; - if (geoActionBtn) { - geoActionBtn.disabled = false; - geoActionBtn.textContent = "Spróbuj ponownie"; - } - if (menuOnlyBtn) configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); - return; - } - - loadingScreen.classList.add("hidden"); - geoScreen.classList.remove("hidden"); + loadingScreen?.classList.add("hidden"); + geoScreen?.classList.remove("hidden"); configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); - if (!navigator.geolocation) { - geoMsg.innerHTML = "Twoja przeglądarka nie wspiera geolokalizacji. Aplikacja wymaga nowszej przeglądarki."; + if (!window.isSecureContext) { + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus("Ta strona wymaga bezpiecznego połączenia HTTPS.", { error: true }); + showGeoInstructions("Przeglądarki mobilne blokują geolokalizację bez https://. Otwórz aplikację przez HTTPS i spróbuj ponownie."); + setGeoActionBusy(false); + setGeoActionLabel("Spróbuj ponownie"); return; } - geoMsg.innerHTML = "Sprawdzamy Twoją lokalizację..."; - trackEvent("geo_check_started"); - if (geoActionBtn) { - geoActionBtn.disabled = true; - geoActionBtn.textContent = "Sprawdzanie..."; + if (!navigator.geolocation) { + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus("Twoja przeglądarka nie wspiera geolokalizacji.", { error: true }); + hideGeoInstructions(); + setGeoActionBusy(false); + return; } - navigator.geolocation.getCurrentPosition( - (position) => { - const dist = haversineDistance( - RESTAURANT_LAT, RESTAURANT_LNG, - position.coords.latitude, position.coords.longitude - ); + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus("Sprawdzamy Twoją lokalizację…", { info: true }); + hideGeoInstructions(); + setGeoActionBusy(true); - const accuracy = position.coords.accuracy; - console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`); + const permissionState = await queryGeolocationPermissionState(); + if (permissionState === "denied") { + showGeoPermissionBlockedState(); + return; + } - if (dist <= MAX_DISTANCE_METERS) { - trackEvent("geo_check_passed", { distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) }); - unlockFullApp(); - // setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000); - } else { - trackEvent("geo_check_failed", { reason: "outside_restaurant", distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) }); - if (geoActionBtn) { - geoActionBtn.disabled = false; - geoActionBtn.textContent = "Spróbuj ponownie"; - } - configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); - geoMsg.innerHTML = `Wydaje się, że jesteś poza restauracją (ok. ${Math.round(dist)}m od nas).
Nasza aplikacja działa tylko na miejscu.

- Debug: Twoja odległość: ${Math.round(dist)}m, Dokładność sygnału: ${Math.round(accuracy)}m

- 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 = `Przeglądarka zablokowała lokalizację z powodu braku HTTPS.

- Geolokalizacja działa tylko na bezpiecznym adresie https:// (lub localhost).
- Otwórz aplikację przez HTTPS i spróbuj ponownie.

- Masz problem z lokalizacją? Połącz się z HotSpot Karczmy, a wtedy wejdziesz do aplikacji bez geolokalizacji.
- Hasło: karczmabiesiada`; - } else if (error.code === error.PERMISSION_DENIED) { - const isIOS = isIOSDevice(); - - let instructions = ''; - if (isIOS) { - instructions = `Instrukcja dla iPhone (Safari):
- 1. Kliknij ikonę "aA" po lewej stronie paska adresu.
- 2. Wybierz "Ustawienia witryny" (Website Settings).
- 3. Zmień opcję "Położenie" (Location) na "Zapytaj" lub "Pozwalaj".
- 4. Odśwież stronę.

- Uwaga: Na urządzeniach Apple lokalizacja działa WYŁĄCZNIE, gdy adres strony zaczyna się od bezpiecznego https://. Jeżeli jesteś na http://, system zablokuje to automatycznie.

- Masz problem z lokalizacją? Połącz się z HotSpot Karczmy, a wtedy wejdziesz do aplikacji bez geolokalizacji.
- Hasło: karczmabiesiada`; - } else { - instructions = `Instrukcja dla Android / Chrome:
- 1. Kliknij ikonkę kłódki / ustawień 🔒 obok adresu strony na górze przeglądarki.
- 2. Znajdź Uprawnienia (Lokalizacja) i zmień z "Zablokuj" na "Zezwalaj".
- 3. Odśwież stronę.

- Masz problem z lokalizacją? Połącz się z HotSpot Karczmy, a wtedy wejdziesz do aplikacji bez geolokalizacji.
- Hasło: karczmabiesiada`; - } + trackEvent("geo_check_started"); - geoMsg.innerHTML = `Przeglądarka zablokowała dostęp do lokalizacji.

- Bez tego nie możemy zweryfikować, czy jesteś w restauracji.

- ${instructions}`; - } else { - geoMsg.innerHTML = "Nie udało się pobrać lokalizacji. Sprawdź zasięg lub włącz GPS i spróbuj ponownie."; - } - }, - { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } - ); + try { + const result = await requestRestaurantGeolocation(); + const dist = result.dist; + const accuracy = result.accuracy; + + console.log( + `[GEO] Lat: ${result.position.coords.latitude}, Lng: ${result.position.coords.longitude}, ` + + `MinDist: ${Math.round(dist)}m, Accuracy: ${Math.round(accuracy)}m` + ); + + if (result.passed) { + trackEvent("geo_check_passed", { + distanceMeters: Math.round(dist), + accuracyMeters: Math.round(accuracy), + }); + unlockFullApp(); + return; + } + + trackEvent("geo_check_failed", { + reason: "outside_restaurant", + distanceMeters: Math.round(dist), + accuracyMeters: Math.round(accuracy), + }); + setGeoActionBusy(false); + setGeoActionLabel("Spróbuj ponownie"); + configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); + setGeoStatus( + `Wygląda na to, że jesteś poza restauracją (ok. ${Math.round(dist)} m, dokładność GPS: ±${Math.round(accuracy)} m).`, + { error: true } + ); + showGeoInstructions( + "Przeglądarka często podaje inną lokalizację niż aplikacja Map Google. " + + "Spróbuj ponownie na zewnątrz lub bliżej okna — albo przejdź do menu bez lokalizacji." + ); + } catch (error) { + trackEvent("geo_check_failed", { + reason: "browser_error", + code: error.code || null, + message: String(error.message || ""), + }); + setGeoActionBusy(false); + setGeoActionLabel("Spróbuj ponownie"); + configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); + const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || "")); + + if (deniedBecauseInsecure) { + setGeoStatus("Geolokalizacja wymaga HTTPS.", { error: true }); + showGeoInstructions("Otwórz aplikację przez bezpieczny adres https:// i spróbuj ponownie."); + } else if (isGeoPermissionDenied(error)) { + showGeoPermissionBlockedState(); + } else { + setGeoStatus("Nie udało się pobrać lokalizacji.", { error: true }); + showGeoInstructions("Sprawdź zasięg, włącz GPS i spróbuj ponownie."); + } + } }; +bindGeoScreenButtons(); + if (shouldBypassGeolocationHost()) { - startApp(); -} else if (isIOSDevice()) { - showGeoConsentScreen(); + bypassGeolocation("trusted_host", { host: window.location.hostname }); } else { - initGeolocation(); + bootstrapGeolocation(); } \ No newline at end of file diff --git a/public/includes/asset_version.php b/public/includes/asset_version.php new file mode 100644 index 0000000..65fe1f3 --- /dev/null +++ b/public/includes/asset_version.php @@ -0,0 +1,23 @@ +/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 + ? `
Kelner stolika: ${escapeHtml(operator)}
` + : ''; + const pending = Number(row.status_kds) === 0; + const cardClass = [ + 'feed-card', + isWaiter ? 'waiter' : 'bill', + pending ? '' : 'done', + ].filter(Boolean).join(' '); + + return ` +
+
+
${escapeHtml(typeLabel)}
+
${escapeHtml(formatTime(row.created_at))}
+
+
Stolik ${escapeHtml(row.table_id || '?')}
+ ${operatorHtml} +
${escapeHtml(row.message_text || '')}
+
+ + ${pending ? 'Aktywne w KDS' : 'Obsłużone w KDS'} + +
+
+ `; + }).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(); +})(); diff --git a/public/waiter/index.php b/public/waiter/index.php new file mode 100644 index 0000000..3fc2419 --- /dev/null +++ b/public/waiter/index.php @@ -0,0 +1,66 @@ + + + + + + + + + Kelner – wezwania + + + + +
+
+

Panel kelnera

+

Wezwania i prośby o rachunek · dziś

+
+
+ + Łączenie… +
+
+ + + +
+
+ Aktywne + +
+
+ Kelner + +
+
+ Rachunek + +
+
+ Razem dziś + +
+
+ +
+ + + + + + diff --git a/public/waiter/sw.js b/public/waiter/sw.js new file mode 100644 index 0000000..34e47dc --- /dev/null +++ b/public/waiter/sw.js @@ -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, + }) + ); +});