diff --git a/api/guest_action_queue.php b/api/guest_action_queue.php index 861ec95..2de423b 100644 --- a/api/guest_action_queue.php +++ b/api/guest_action_queue.php @@ -47,6 +47,29 @@ function hasPendingGuestAction(PDO $pdo, string $tableId, string $messageType): return (bool) $stmt->fetchColumn(); } +/** + * Limit: max 1 nowe zgłoszenie danego typu na stolik w oknie czasowym + * (nawet po obsłużeniu poprzedniego w KDS). + */ +function isGuestActionRateLimited(PDO $pdo, string $tableId, string $messageType, int $windowSeconds = 60): bool +{ + $windowSeconds = max(1, min(600, $windowSeconds)); + $stmt = $pdo->prepare(" + SELECT 1 + FROM guest_action_queue + WHERE table_id = :table_id + AND message_type = :message_type + AND created_at >= (NOW(3) - INTERVAL {$windowSeconds} SECOND) + LIMIT 1 + "); + $stmt->execute([ + ':table_id' => $tableId, + ':message_type' => $messageType, + ]); + + return (bool) $stmt->fetchColumn(); +} + function fetchPendingGuestActions(PDO $pdo, string $tableId): array { $stmt = $pdo->prepare(" @@ -316,6 +339,18 @@ try { exit; } + if (isGuestActionRateLimited($pdo, $tableId, $messageType, 60)) { + http_response_code(429); + echo json_encode([ + 'status' => 'error', + 'code' => 'rate_limited', + 'message' => $messageType === 'waiter_call' + ? 'Wezwanie zostało już niedawno wysłane. Spróbuj ponownie za chwilę.' + : 'Prośba o rachunek została już niedawno wysłana. Spróbuj ponownie za chwilę.', + ], JSON_UNESCAPED_UNICODE); + exit; + } + $stmt = $pdo->prepare(" INSERT INTO guest_action_queue ( table_id, diff --git a/api/gus_lookup.php b/api/gus_lookup.php new file mode 100644 index 0000000..4fa5eeb --- /dev/null +++ b/api/gus_lookup.php @@ -0,0 +1,116 @@ + 'error', + 'message' => 'Method not allowed', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$nipRaw = ''; +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $nipRaw = isset($_GET['nip']) ? (string) $_GET['nip'] : ''; +} else { + $rawBody = file_get_contents('php://input'); + $data = json_decode($rawBody ?: '', true); + if (is_array($data) && isset($data['nip'])) { + $nipRaw = (string) $data['nip']; + } elseif (isset($_POST['nip'])) { + $nipRaw = (string) $_POST['nip']; + } +} + +$nip = preg_replace('/[\s\-]/', '', $nipRaw) ?? ''; + +if ($nip === '' || !preg_match('/^\d{10}$/', $nip)) { + http_response_code(422); + echo json_encode([ + 'status' => false, + 'message' => 'Podaj poprawny 10-cyfrowy NIP.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$config = require __DIR__ . '/../config/gus.php'; +$apiKey = trim((string) ($config['api_key'] ?? '')); +$baseUrl = rtrim((string) ($config['base_url'] ?? ''), '/'); +$timeout = (int) ($config['timeout_seconds'] ?? 12); + +if ($apiKey === '' || $baseUrl === '') { + http_response_code(500); + echo json_encode([ + 'status' => false, + 'message' => 'Brak konfiguracji GUS.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$url = $baseUrl . '/' . rawurlencode($nip) . '?api_key=' . rawurlencode($apiKey); + +$responseBody = false; +$httpCode = 0; + +if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => max(3, $timeout), + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + ], + ]); + + $responseBody = curl_exec($ch); + $curlErrno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($curlErrno !== 0) { + $responseBody = false; + } +} else { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => max(3, $timeout), + 'header' => "Accept: application/json\r\n", + 'ignore_errors' => true, + ], + ]); + $responseBody = @file_get_contents($url, false, $context); + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $httpCode = (int) $m[1]; + } +} + +if ($responseBody === false) { + http_response_code(502); + echo json_encode([ + 'status' => false, + 'message' => 'Błąd połączenia z API GUS.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$decoded = json_decode($responseBody, true); +if (!is_array($decoded)) { + http_response_code(502); + echo json_encode([ + 'status' => false, + 'message' => 'Niepoprawna odpowiedź API GUS.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($httpCode < 200 || $httpCode >= 300) { + http_response_code($httpCode >= 400 ? $httpCode : 502); +} + +echo json_encode($decoded, JSON_UNESCAPED_UNICODE); diff --git a/api/request_ip.php b/api/request_ip.php index d76f5a6..c67daf9 100644 --- a/api/request_ip.php +++ b/api/request_ip.php @@ -14,6 +14,19 @@ function getRequestClientIp(): string return trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); } +/** + * Hostnamey, dla których frontend pomija geolokalizację (dev / znany host). + * Wstrzykiwane do window.APP_CONFIG przez app.php. + */ +function getGeoBypassTrustedHosts(): array +{ + return [ + '82.160.190.247', + 'localhost', + '127.0.0.1', + ]; +} + /** * Pojedyncze IP z pominięciem geo (zewnętrzne, dev, przykładowe hosty LAN). */ diff --git a/config/gus.php b/config/gus.php new file mode 100644 index 0000000..6fbc93e --- /dev/null +++ b/config/gus.php @@ -0,0 +1,12 @@ + 'd8bdc252-e0f1-4863-97a1-826faddbc49c', + 'base_url' => 'https://api.magico.pro/v1/gus', + 'timeout_seconds' => 12, +]; diff --git a/public/app.php b/public/app.php index 99fdb01..42669e7 100644 --- a/public/app.php +++ b/public/app.php @@ -5,9 +5,10 @@ header('Pragma: no-cache'); header('Expires: 0'); require_once __DIR__ . '/includes/asset_version.php'; +require_once __DIR__ . '/../api/request_ip.php'; $publicDir = __DIR__; $vCss = publicAssetVersion($publicDir, 'assets/css/app.css'); -$vJs = publicAssetVersion($publicDir, 'assets/js/app.js'); +$vJs = publicJsBundleVersion($publicDir, 'assets/js/app.js'); $vMenu = publicAssetVersion($publicDir, 'menu.json'); ?> @@ -383,8 +384,20 @@ $vMenu = publicAssetVersion($publicDir, 'menu.json'); - + \ No newline at end of file diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 24e3552..4704708 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,21 +1,64 @@ -window.kitchenAnimations = [ - `
🔥
🍳
`, - `
🐓
👨‍🍳
`, - `
🐓
🍲
`, - `
👨‍🍳
🍕
`, - `
🐷
💦
🍴
` -]; -window.selectedAnimationHtml = null; - -const MENU_ASSET_VERSION = - window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1"; +import { trackEvent } from "./modules/analytics.js"; +import { + promptGeoForFullAccess, + setProtectedActionHandlers, + switchTab, +} from "./modules/access.js"; +import { + callWaiter, + closeBillDialog, + closeWaiterDialog, + confirmCallWaiter, + confirmInvoice, + editCompanyData, + fetchGUS, + goBackToBillList, + goToStep, + openBillDialog, + openBillDialogInternal, + openWaiterDialog, + openWaiterDialogInternal, + proceedToBillPayment, + selectDocument, + selectPayment, + showBillReview, +} from "./modules/bill.js"; +import { + bindItemModalSwipe, + closeItemModal, + filterMenu, + handleMenuImageError, + loadMenu, + navigateItemModal, + openItemModal, + showCategory, +} from "./modules/menu.js"; +import { clearGlobalHistory } from "./modules/orders.js"; +import { + declineUserName, + enterMenuOnlyMode, + handleGeoMenuClick, + initGeolocation, + retryGeolocation, + saveUserName, + startGeoBootstrap, +} from "./modules/geo.js"; +import { + getHashParam, + setHashParam, + setIsStaffPreview, + setTableParam, +} from "./modules/state.js"; +import { showToast } from "./modules/toast.js"; +// Parse URL into shared state const params = new URLSearchParams(location.search); -let hashParam = (params.get("h") || "").trim(); -const isStaffPreview = params.get("preview") === "staff"; +setHashParam((params.get("h") || "").trim()); +setIsStaffPreview(params.get("preview") === "staff"); +setTableParam(""); // Puste, zostanie uzupełnione przez backend // Jeśli brak hasha w URL – zapytaj użytkownika (np. do testów) -if (!hashParam) { +if (!getHashParam()) { const input = prompt("Podaj bezpieczny hash stolika (wymagane):"); const trimmed = (input || "").trim(); if (trimmed) { @@ -25,2142 +68,48 @@ if (!hashParam) { } } -let tableParam = ""; // Puste, zostanie uzupełnione przez backend -let appAccessLevel = "none"; // "none" | "menu" | "full" -let pendingProtectedAction = null; // "status" | "waiter" | "bill" -const analyticsEndpoint = "../api/analytics.php"; -const guestActionQueueEndpoint = "../api/guest_action_queue.php"; -const analyticsSessionKey = "karczma_analytics_session_id"; +setProtectedActionHandlers({ + waiter: openWaiterDialogInternal, + bill: openBillDialogInternal, +}); -function getOrCreateAnalyticsSessionId() { - let existing = localStorage.getItem(analyticsSessionKey); - if (existing) return existing; +// window.* for inline onclick in app.php +window.saveUserName = saveUserName; +window.declineUserName = declineUserName; +window.handleGeoMenuClick = handleGeoMenuClick; +window.retryGeolocation = retryGeolocation; +window.promptGeoForFullAccess = promptGeoForFullAccess; +window.enterMenuOnlyMode = enterMenuOnlyMode; +window.clearGlobalHistory = clearGlobalHistory; +window.callWaiter = callWaiter; +window.openWaiterDialog = openWaiterDialog; +window.closeWaiterDialog = closeWaiterDialog; +window.confirmCallWaiter = confirmCallWaiter; +window.proceedToBillPayment = proceedToBillPayment; +window.openBillDialog = openBillDialog; +window.goBackToBillList = goBackToBillList; +window.showBillReview = showBillReview; +window.closeBillDialog = closeBillDialog; +window.goToStep = goToStep; +window.switchTab = switchTab; +window.filterMenu = filterMenu; +window.showCategory = showCategory; +window.selectPayment = selectPayment; +window.selectDocument = selectDocument; +window.fetchGUS = fetchGUS; +window.handleMenuImageError = handleMenuImageError; +window.navigateItemModal = navigateItemModal; +window.openItemModal = openItemModal; +window.closeItemModal = closeItemModal; +window.editCompanyData = editCompanyData; +window.confirmInvoice = confirmInvoice; +window.initGeolocation = initGeolocation; +window.showToast = showToast; - let newId = ""; - if (window.crypto && crypto.randomUUID) { - newId = crypto.randomUUID(); - } else { - newId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - } - localStorage.setItem(analyticsSessionKey, newId); - return newId; -} - -const analyticsSessionId = getOrCreateAnalyticsSessionId(); - -function detectDeviceType() { - const ua = navigator.userAgent || ""; - if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)) return "ios"; - if (/Android/i.test(ua)) return "android"; - return "other"; -} - -function detectBrowser() { - const ua = navigator.userAgent || ""; - if (/Edg\//.test(ua)) return "edge"; - if (/OPR\//.test(ua)) return "opera"; - if (/Chrome\//.test(ua)) return "chrome"; - if (/Safari\//.test(ua)) return "safari"; - if (/Firefox\//.test(ua)) return "firefox"; - return "other"; -} - -function deriveZoneFromTable(tableValue) { - const raw = String(tableValue || "").trim().toLowerCase(); - if (!raw) return null; - - if (raw.startsWith("t") || raw.includes("taras")) return "taras"; - if (raw.startsWith("k") || raw.includes("karczma")) return "karczma"; - return null; -} - -function trackEvent(eventName, payload = {}) { - if (isStaffPreview) { - return; - } - - const body = { - eventName, - sessionId: analyticsSessionId, - tableId: tableParam || null, - zone: deriveZoneFromTable(tableParam), - qrHash: hashParam || null, - deviceType: detectDeviceType(), - browser: detectBrowser(), - payload - }; - - const bodyString = JSON.stringify(body); - - try { - if (navigator.sendBeacon) { - const blob = new Blob([bodyString], { type: "application/json" }); - navigator.sendBeacon(analyticsEndpoint, blob); - return; - } - } catch { - // fallback to fetch below - } - - fetch(analyticsEndpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: bodyString, - keepalive: true - }).catch(() => { - // best effort - ignore analytics errors - }); -} - -let cachedOpenBills = []; -let guestPendingActions = { - waiter_call: false, - bill_request: false, -}; -let guestPendingPollTimer = null; -const GUEST_PENDING_POLL_MS = 15000; - -function cacheOpenBills(bills) { - cachedOpenBills = Array.isArray(bills) ? bills : []; -} - -function getQueueOperatorFields() { - const bills = cachedOpenBills; - if (!bills.length) { - return { otwierajacyImie: "", otwierajacyNazwisko: "" }; - } - - let bill = bills[0]; - if (billState.selectedBillId) { - const selected = bills.find((b) => b.id === billState.selectedBillId); - if (selected) bill = selected; - } - - const o = bill.otwierajacy || {}; - return { - otwierajacyImie: String(o.imie || "").trim(), - otwierajacyNazwisko: String(o.nazwisko || "").trim(), - }; -} - -async function prefetchOpenBills() { - if (!hashParam) return; - try { - const res = await fetch(`../api/bills.php?h=${encodeURIComponent(hashParam)}`); - const result = await res.json(); - if (result.status === "success") { - cacheOpenBills(result.data); - } - } catch { - // best effort - } -} - -/** - * Komunikat do kolejki KDS — zwykły tekst, wiersze oddzielone \n (w JSON jako entery). - * @param {string} title - * @param {{ label?: string, value?: string }[]} lines - */ -function formatGuestQueueMessage(title, lines = []) { - const rows = (Array.isArray(lines) ? lines : []) - .map((line) => { - const label = String(line?.label ?? "").trim(); - const value = String(line?.value ?? "").trim(); - if (!label && !value) return ""; - if (label && value) return `${label} ${value}`; - return label || value; - }) - .filter(Boolean); - - if (!rows.length) { - return title; - } - - return `${title}\n${rows.join("\n")}`; -} - -function buildWaiterCallQueueMessage() { - return "Przywołanie kelnera"; -} - -function buildBillRequestQueueMessage(docType) { - const lines = [ - { label: "Forma płatności:", value: billState.payment || "nieznana" }, - { label: "Dokument:", value: docType === "faktura" ? "faktura" : "paragon" }, - ]; - - if (docType === "faktura") { - lines.push({ label: "NIP:", value: billState.nip || "—" }); - lines.push({ label: "Firma:", value: billState.company?.name || "—" }); - const addressParts = [ - billState.company?.street, - [billState.company?.zip, billState.company?.city].filter(Boolean).join(" "), - ].filter(Boolean); - if (addressParts.length) { - lines.push({ label: "Adres:", value: addressParts.join(", ") }); - } - } - - return formatGuestQueueMessage("Prośba o rachunek", lines); -} - -function guestActionBlockedMessage(messageType) { - if (messageType === "waiter_call") { - return "Kelner został już wezwany. Poczekaj, aż obsługa potwierdzi zgłoszenie na panelu."; - } - return "Prośba o rachunek została już wysłana. Poczekaj, aż obsługa ją obsłuży."; -} - -function updateGuestActionNavState() { - const waiterNav = document.querySelector(".bottom-nav .action-call"); - const billNav = document.querySelector(".bottom-nav .action-bill"); - if (waiterNav) { - waiterNav.classList.toggle("nav-action-pending", guestPendingActions.waiter_call); - } - if (billNav) { - billNav.classList.toggle("nav-action-pending", guestPendingActions.bill_request); - } -} - -async function refreshGuestPendingActions() { - if (!hashParam && !tableParam) { - return guestPendingActions; - } - - const params = new URLSearchParams(); - if (hashParam) params.set("h", hashParam); - if (tableParam) params.set("tableId", tableParam); - - try { - const res = await fetch(`${guestActionQueueEndpoint}?${params.toString()}`); - const result = await res.json(); - if (result.status === "success" && result.pending) { - guestPendingActions.waiter_call = !!result.pending.waiter_call; - guestPendingActions.bill_request = !!result.pending.bill_request; - updateGuestActionNavState(); - } - } catch { - // best effort - } - - return guestPendingActions; -} - -function startGuestPendingPoll() { - if (guestPendingPollTimer) return; - guestPendingPollTimer = setInterval(() => { - if (!hashParam && !tableParam) return; - refreshGuestPendingActions(); - }, GUEST_PENDING_POLL_MS); -} - -async function ensureGuestActionAllowed(messageType) { - await refreshGuestPendingActions(); - if (!guestPendingActions[messageType]) { - return true; - } - showToast(guestActionBlockedMessage(messageType)); - return false; -} - -async function queueGuestAction(messageType, messageText, extra = {}) { - const operator = getQueueOperatorFields(); - const body = { - tableId: tableParam || null, - qrHash: hashParam || null, - messageType, - messageText, - otwierajacyImie: operator.otwierajacyImie, - otwierajacyNazwisko: operator.otwierajacyNazwisko, - extra, - }; - - try { - const res = await fetch(guestActionQueueEndpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - keepalive: true, - }); - const result = await res.json().catch(() => ({})); - - if (res.status === 409 || result.code === "pending_on_kds") { - guestPendingActions[messageType] = true; - updateGuestActionNavState(); - return { ok: false, reason: "pending" }; - } - - if (!res.ok || result.status !== "success") { - return { ok: false, reason: "error" }; - } - - guestPendingActions[messageType] = true; - updateGuestActionNavState(); - return { ok: true }; - } catch { - return { ok: false, reason: "error" }; - } -} - -if (hashParam) { +if (getHashParam()) { trackEvent("qr_scan", { source: "qr_link_open" }); } -// USER PROFILE LOGIC -const userProfileKey = "karczma_user_profile"; -const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months - -function initUserProfile() { - return; // Funkcja tymczasowo wyłączona - try { - const raw = localStorage.getItem(userProfileKey); - let profile = null; - if (raw) { - profile = JSON.parse(raw); - } - - const now = Date.now(); - - // Check if profile exists and is valid - if (profile) { - if (profile.declined) { - // User declined in the past, don't ask again. - return; - } - - // If expired, maybe we want to ask again or we could just keep it if they didn't decline. - // The user said: "trzymamy przez wiele miesięcy (odnawiamy datę przy każdej wizycie)" - if (now - profile.lastVisit > USER_PROFILE_EXPIRE_MS) { - // Profile expired. Ask again. - showNameDialog(); - } else { - // Profile valid, renew date and show greeting - profile.lastVisit = now; - localStorage.setItem(userProfileKey, JSON.stringify(profile)); - showGreeting(profile.name, profile.firstVisit || profile.lastVisit); - } - } else { - // No profile, ask for name - showNameDialog(); - } - } catch (err) { - // If error parsing, ask again - showNameDialog(); - } -} - -function showNameDialog() { - const modal = document.getElementById("nameModal"); - if (modal) { - modal.classList.add("active"); - document.body.style.overflow = 'hidden'; - } -} - -function hideNameDialog() { - const modal = document.getElementById("nameModal"); - if (modal) { - modal.classList.remove("active"); - document.body.style.overflow = ''; - } -} - -window.saveUserName = function () { - const input = document.getElementById("userNameInput").value.trim(); - if (input) { - const now = Date.now(); - const profile = { name: input, firstVisit: now, lastVisit: now, declined: false }; - localStorage.setItem(userProfileKey, JSON.stringify(profile)); - hideNameDialog(); - showGreeting(profile.name, profile.firstVisit); - } -}; - -window.declineUserName = function () { - const profile = { name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true }; - localStorage.setItem(userProfileKey, JSON.stringify(profile)); - hideNameDialog(); -}; - -function showGreeting(name, firstVisitTime) { - const banner = document.getElementById("greetingBanner"); - if (banner && name) { - const isToday = new Date(firstVisitTime).toDateString() === new Date().toDateString(); - - if (isToday) { - banner.innerHTML = `Cześć ${name}, życzymy pysznego posiłku!`; - } else { - banner.innerHTML = `Witaj ${name}, super że do nas wracasz!`; - } - banner.style.display = "block"; - } -} - -// Call init is now delayed until geolocation succeeds -// initUserProfile(); - -// UI Elements -const loadingScreen = document.getElementById("loadingScreen"); -const loaderMsg = document.getElementById("loaderMsg"); -const tableLabel = document.getElementById("tableLabel"); -const prepStatus = document.getElementById("prepStatus"); -const progressBar = document.getElementById("progressBar"); -const statusMeta = document.getElementById("statusMeta"); -const itemsList = document.getElementById("itemsList"); -const emptyState = document.getElementById("emptyState"); -const metaFooter = document.getElementById("metaFooter"); -const statusIcon = document.getElementById("statusIcon"); -const storageKey = `stolik2_state_${(tableParam || "unknown").toLowerCase()}`; -const historyKey = "stolik2_global_history"; -const historySection = document.getElementById("historySection"); -const historyList = document.getElementById("historyList"); -const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000; -const HOT_WINDOW_MS = 5 * 60 * 60 * 1000; - -// Dynamic Loader Messages -const LOADER_MIN_MS = 10_000; -const loadStartTime = Date.now(); - -const msgs = ["Rozgrzewamy piece...", "Szef kuchni sprawdza składniki...", "Łączenie z sercem restauracji...", "Prawie gotowe..."]; -let msgIdx = 0; -const msgInterval = setInterval(() => { - msgIdx = (msgIdx + 1) % msgs.length; - loaderMsg.textContent = msgs[msgIdx]; -}, 4000); - -// Initial State -if (tableParam) { - tableLabel.textContent = tableParam.toUpperCase().startsWith("STOLIK") ? tableParam : `Stolik ${tableParam}`; -} - -function hideLoader() { - const elapsed = Date.now() - loadStartTime; - const remaining = Math.max(0, LOADER_MIN_MS - elapsed); - setTimeout(() => { - loadingScreen.classList.add("hidden"); - clearInterval(msgInterval); - const bottomNav = document.getElementById("bottomNav"); - if (bottomNav) { - bottomNav.style.display = ""; - } - updateNavAccessState(); - if (pendingProtectedAction && appAccessLevel === "full") { - runPendingProtectedAction(); - } - }, remaining); -} - -function updateNavAccessState() { - const locked = appAccessLevel === "menu"; - ["navStatus", "navWaiter", "navBill"].forEach((id) => { - const el = document.getElementById(id); - if (el) el.classList.toggle("nav-locked", locked); - }); - const banner = document.getElementById("menuOnlyBanner"); - if (banner) { - banner.classList.remove("hidden"); - banner.classList.toggle("is-hidden", appAccessLevel !== "menu"); - } -} - -function showBottomNav() { - const bottomNav = document.getElementById("bottomNav"); - if (bottomNav) bottomNav.style.display = ""; - updateNavAccessState(); -} - -async function resolveTableLabel() { - if (!hashParam) return; - try { - const response = await fetch(`../api/kds.php?h=${encodeURIComponent(hashParam)}`); - const result = await response.json(); - if (result.status === "success" && result.tableName && result.tableName !== "") { - tableLabel.textContent = result.tableName.toUpperCase().startsWith("STOLIK") - ? result.tableName - : `Stolik ${result.tableName}`; - tableParam = result.tableName; - } - } catch { - // best effort - } -} - -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 = ""; - if (mainEl) mainEl.textContent = "Wróć do menu"; - if (subEl) { - subEl.textContent = ""; - subEl.style.display = "none"; - } - return; - } - - menuOnlyBtn.style.display = ""; - 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 geoActionBtn = document.getElementById("geoActionBtn"); - - if (loadingScreen) loadingScreen.classList.add("hidden"); - if (geoScreen) geoScreen.classList.remove("hidden"); - - const feature = GEO_GATE_LABELS[action] || "tę funkcję"; - setGeoLead(`Menu masz już otwarte. Aby skorzystać z ${feature}, potwierdź krótko, że jesteś w restauracji.`); - setGeoStatus(""); - hideGeoInstructions(); - if (geoActionBtn) { - setGeoActionBusy(false); - setGeoActionLabel("Sprawdź lokalizację"); - } - configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); -} - -function requireFullAccess(action, onGranted) { - if (appAccessLevel === "full") { - onGranted(); - return; - } - pendingProtectedAction = action; - trackEvent("geo_gate_prompted", { action }); - if (appAccessLevel === "menu") { - trackEvent("geo_retry_from_menu", { action }); - } - showGeoGateForAction(action); -} - -window.promptGeoForFullAccess = function () { - pendingProtectedAction = pendingProtectedAction || "status"; - trackEvent("geo_gate_prompted", { action: pendingProtectedAction, source: "menu_banner" }); - trackEvent("geo_retry_from_menu", { action: pendingProtectedAction, source: "menu_banner" }); - showGeoGateForAction(pendingProtectedAction); -}; - -function runPendingProtectedAction() { - const action = pendingProtectedAction; - pendingProtectedAction = null; - if (!action) return; - - setTimeout(() => { - if (action === "status") switchTabInternal("status"); - else if (action === "waiter") openWaiterDialogInternal(); - else if (action === "bill") openBillDialogInternal(); - }, 150); -} - -function unlockFullApp() { - appAccessLevel = "full"; - updateNavAccessState(); - document.getElementById("geoScreen")?.classList.add("hidden"); - - const loaderVisible = !document.getElementById("loadingScreen")?.classList.contains("hidden"); - const alreadyStarted = !!window.ordersInterval; - - if (alreadyStarted && !loaderVisible) { - trackEvent("session_start", { flow: "unlock_from_menu" }); - initUserProfile(); - fetchOrders(); - prefetchOpenBills(); - refreshGuestPendingActions(); - startGuestPendingPoll(); - runPendingProtectedAction(); - return; - } - - startApp(); -} - -window.enterMenuOnlyMode = function () { - trackEvent("menu_only_entered"); - appAccessLevel = "menu"; - pendingProtectedAction = null; - - document.getElementById("geoScreen")?.classList.add("hidden"); - document.getElementById("loadingScreen")?.classList.add("hidden"); - showBottomNav(); - resolveTableLabel(); - switchTabInternal("menu"); -}; - -function updateUI(bills) { - // Hide loader after minimum display time - hideLoader(); - - const allArticles = bills.flatMap(b => Array.isArray(b?.Articles) ? b.Articles : []); - const items = mergeWithPersistedItems(allArticles); - renderGlobalHistory(); - - if (items.length === 0) { - showEmptyState(); - return; - } - - renderItems(items); - updateStatus(bills, items); -} - -function detectTableCandidates(bill) { - const remark = String(bill?.Remark || ""); - const description = String(bill?.Description || "").trim(); - - // 1) klasyczny format: "STOLIK 9", "STOLIK 11A" - const stolikMatch = remark.match(/STOLIK\s*([0-9A-Z]+)/i); - const fromRemark = stolikMatch ? stolikMatch[1].toLowerCase() : ""; - - // 2) czasem numer bywa w samym Description - const fromDescription = description.toLowerCase(); - - return { fromRemark, fromDescription, remark: remark.toLowerCase() }; -} - -function showEmptyState() { - const title = document.getElementById("ordersTitle"); - if (title) title.classList.add("hidden"); - - emptyState.classList.remove("hidden"); - itemsList.innerHTML = ""; - prepStatus.textContent = "Brak aktywnych zamówień"; - statusIcon.textContent = "🍃"; - progressBar.style.width = "0%"; - statusMeta.textContent = "Zapraszamy do sprawdzenia naszego menu."; - // Historia może istnieć nawet gdy brak bieżących pozycji - renderGlobalHistory(); -} - -function normalizeArticleName(rawName) { - const name = String(rawName || "Pozycja"); - - // Usuwa gramatury typu: "300G", "250 G", "500/200/150G". - const withoutWeight = name.replace( - /\b\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)*\s*[gG]\b/g, - "" - ); - - return withoutWeight - .replace(/\s{2,}/g, " ") - .replace(/\s+([,.;:!?])/g, "$1") - .trim() || "Pozycja"; -} - -function loadPersistedItems() { - try { - const raw = localStorage.getItem(storageKey); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function savePersistedItems(items) { - try { - localStorage.setItem(storageKey, JSON.stringify(items)); - } catch { - // brak miejsca/tryb prywatny - } -} - -function loadGlobalHistory() { - try { - const raw = localStorage.getItem(historyKey); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function saveGlobalHistory(entries) { - const now = Date.now(); - const cleaned = entries - .filter((e) => e && e.name && Number.isFinite(e.archivedAt)) - .filter((e) => (now - e.archivedAt) <= SIX_MONTHS_MS); - - try { - localStorage.setItem(historyKey, JSON.stringify(cleaned)); - } catch { - // ignore storage errors - } -} - -function addItemsToGlobalHistory(items, sourceTable) { - if (!items.length) return; - const now = Date.now(); - const existing = loadGlobalHistory(); - - // zapobiegamy dokładnym duplikatom (ta sama nazwa + qty + stół blisko czasu) - const dedupWindowMs = 2 * 60 * 1000; - const toAdd = items.filter((item) => { - return !existing.some((h) => - h.name === item.name && - Number(h.qty) === Number(item.qty) && - String(h.sourceTable || "") === String(sourceTable || "") && - Math.abs((h.archivedAt || 0) - now) <= dedupWindowMs - ); - }).map((item) => ({ - name: item.name, - qty: item.qty, - sourceTable: sourceTable || "?", - archivedAt: now - })); - - if (!toAdd.length) return; - saveGlobalHistory([...existing, ...toAdd]); -} - -function renderGlobalHistory() { - const now = Date.now(); - const history = loadGlobalHistory() - .filter((e) => (now - (e.archivedAt || 0)) <= SIX_MONTHS_MS) - .sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0)); - - saveGlobalHistory(history); - - if (!history.length) { - historySection.classList.add("hidden"); - historyList.innerHTML = ""; - return; - } - - historySection.classList.remove("hidden"); - historyList.innerHTML = ""; - - history.forEach((entry) => { - const dt = new Date(entry.archivedAt || Date.now()); - const div = document.createElement("div"); - div.className = "item-card archived ready"; - div.innerHTML = ` -
- ${entry.name} - Stolik ${entry.sourceTable || "?"} • ${dt.toLocaleDateString("pl-PL")} ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
x${entry.qty}
- `; - historyList.appendChild(div); - }); -} - -window.clearGlobalHistory = function (e) { - if (e) e.preventDefault(); - if (confirm("Czy na pewno chcesz usunąć historię swoich poprzednich zamówień?")) { - localStorage.removeItem(historyKey); - renderGlobalHistory(); - } -}; - -function mergeWithPersistedItems(articles) { - const current = new Map(); - - articles.forEach(a => { - const name = normalizeArticleName(a.Name); - const todo = parseFloat(String(a.QuantityToDo || a.QuantitySet || "0").replace(",", ".")); - const done = parseFloat(String(a.QuantityDone || "0").replace(",", ".")); - - if (!current.has(name)) { - current.set(name, { name, qty: 0, done: 0, present: true, completedByDisappear: false }); - } - const curr = current.get(name); - curr.qty += Number.isFinite(todo) ? todo : 0; - curr.done += Number.isFinite(done) ? done : 0; - }); - - const persisted = loadPersistedItems(); - const persistedMap = new Map(persisted.map(i => [i.name, i])); - const merged = []; - - // Aktualnie obecne pozycje z WS - current.forEach((item) => { - merged.push({ - ...item, - present: true, - completedByDisappear: false, - updatedAt: Date.now() - }); - }); - - // Pozycje, które były wcześniej, ale zniknęły z API -> przesyłamy od razu do historii globalnej - persistedMap.forEach((oldItem, name) => { - if (current.has(name)) return; - const qty = Number.isFinite(oldItem?.qty) ? oldItem.qty : 0; - - // Zniknęło z bieżącego rachunku (np. rachunek został zamknięty), od razu leci do osobnego bloku historii! - addItemsToGlobalHistory([{ name, qty }], tableParam); - }); - - // Aktywne na górze, gotowe (zniknięte) na dole - merged.sort((a, b) => { - if (a.present !== b.present) return a.present ? -1 : 1; - return a.name.localeCompare(b.name, "pl"); - }); - - savePersistedItems(merged); - return merged; -} - -function renderItems(items) { - const title = document.getElementById("ordersTitle"); - if (title) title.classList.remove("hidden"); - - emptyState.classList.add("hidden"); - itemsList.innerHTML = ""; - - items.forEach((item) => { - const isReady = item.done >= item.qty && item.qty > 0; - const div = document.createElement("div"); - div.className = `item-card ${isReady ? 'ready' : ''} ${item.present ? '' : 'archived'}`; - - let meta = "🔥 W przygotowaniu"; - if (isReady && item.completedByDisappear) { - meta = "✅ Gotowe (zrealizowane)"; - } else if (isReady) { - meta = "✅ Gotowe"; - } - - div.innerHTML = ` -
- ${item.name} - ${meta} -
-
x${item.qty}
- `; - itemsList.appendChild(div); - }); - - renderGlobalHistory(); -} - -function updateStatus(bills, items) { - let total = 0; - let done = 0; - items.forEach(i => { - total += Number.isFinite(i.qty) ? i.qty : 0; - done += Number.isFinite(i.done) ? i.done : 0; - }); - - const pct = total > 0 ? (done / total) * 100 : 0; - progressBar.style.width = `${pct}%`; - - if (pct >= 100) { - prepStatus.textContent = "Gotowe do podania!"; - statusIcon.innerHTML = "😋"; - statusMeta.textContent = "Wszystkie Twoje dania opuściły już kuchnię."; - } else if (pct > 0) { - prepStatus.textContent = "Częściowo gotowe"; - statusIcon.innerHTML = "🍳"; - statusMeta.textContent = "Pierwsze pyszności już na Ciebie czekają!"; - } else { - prepStatus.textContent = "W przygotowaniu"; - if (!window.selectedAnimationHtml) { - window.selectedAnimationHtml = window.kitchenAnimations[Math.floor(Math.random() * window.kitchenAnimations.length)]; - } - statusIcon.innerHTML = window.selectedAnimationHtml; - statusMeta.textContent = "Twoje zamówienie jest właśnie tworzone przez naszych kucharzy."; - } - - // Footer meta - const newest = [...bills].sort((a, b) => new Date(b?.Date || 0) - new Date(a?.Date || 0))[0]; - const time = newest?.Date - ? new Date(newest.Date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : "--:--"; - metaFooter.textContent = `Zamówienie złożone o godzinie ${time} • Stolik ${tableParam}`; -} - -// API Fetch Logic -async function fetchOrders() { - try { - if (!hashParam) { - updateUI([]); - return; - } - - const response = await fetch(`../api/kds.php?h=${encodeURIComponent(hashParam)}`); - const result = await response.json(); - - if (result.status === 'success') { - if (result.tableName && result.tableName !== '') { - tableLabel.textContent = result.tableName.toUpperCase().startsWith("STOLIK") ? result.tableName : `Stolik ${result.tableName}`; - tableParam = result.tableName; // Aktualizacja do właściwej nazwy na poczet innych zapytań - refreshGuestPendingActions(); - startGuestPendingPoll(); - } - - // API teraz samo filtruje i zwraca tylko to co nas interesuje (za pomocą mocnego wyrażenia regularnego) - const matches = result.data; - - // Grupowanie składników w główne dania - const groups = {}; - matches.forEach(item => { - const groupId = item.GrupaZestawuID || item.PozycjaID; - if (!groups[groupId]) { - groups[groupId] = { - Name: item.NazwaZestawu || item.NazwaTowaru, - QuantitySet: item.GrupaZestawuID ? 1 : parseFloat(item.Ilosc), - Done: 0 - }; - } - // StatusRealizacji >= 2 oznacza, że kucharz wcisnął "Gotowe" na swoim ekranie - if (parseInt(item.StatusRealizacji, 10) >= 2) { - groups[groupId].Done = groups[groupId].QuantitySet; - } - }); - - const transformedArticles = Object.values(groups).map(g => ({ - Name: g.Name, - QuantitySet: g.QuantitySet, - QuantityDone: g.Done - })); - - // Najnowszy czas dodania (do pokazania w stopce) - const latestDate = matches.length > 0 - ? matches.sort((a, b) => new Date(b.DataDodania) - new Date(a.DataDodania))[0].DataDodania - : null; - - // Przekazanie do dotychczasowej logiki aktualizującej UI (w odpowiednim formacie) - updateUI([{ - Articles: transformedArticles, - Date: latestDate - }]); - - } else { - loaderMsg.textContent = "Błąd API: " + result.message; - } - } catch (err) { - loaderMsg.textContent = "Problem z połączeniem. Próbujemy ponownie..."; - } -} - -// fetchOrders(); -// setInterval(fetchOrders, 10000); - -// --- CALL WAITER LOGIC --- -let billState = { payment: '', doc: '', nip: '', company: null }; - -function showToast(msg) { - const t = document.getElementById("toastMsg"); - document.getElementById("toastText").textContent = msg; - - t.classList.remove("active"); - void t.offsetWidth; // trigger reflow - t.classList.add("active"); - - setTimeout(() => t.classList.remove("active"), 3500); -} - -function sendApiSimulated(actionName, details) { - console.log(`[SYMULACJA API] Akcja: ${actionName}`, details); - // Przykładowe wysłanie docelowo: - // if (window.socket && window.socket.readyState === WebSocket.OPEN) { - // window.socket.send(JSON.stringify({ action: "sendUpstream", payload: { type: actionName, table: tableParam, ...details } })); - // } -} - -window.callWaiter = async function (type) { - if (type !== "order") return; - - if (!(await ensureGuestActionAllowed("waiter_call"))) { - return; - } - - const queued = await queueGuestAction("waiter_call", buildWaiterCallQueueMessage(), { - waiterType: "order", - }); - - if (!queued.ok) { - if (queued.reason === "pending") { - showToast(guestActionBlockedMessage("waiter_call")); - } else { - showToast("Nie udało się wysłać wezwania. Spróbuj ponownie za chwilę."); - } - return; - } - - trackEvent("waiter_call_requested", { waiterType: "order" }); - sendApiSimulated("CallWaiter_Order", { table: tableParam }); - showToast("Kelner wkrótce do Ciebie podejdzie!"); -}; - -window.openWaiterDialog = function () { - requireFullAccess("waiter", () => { - openWaiterDialogInternal(); - }); -}; - -async function openWaiterDialogInternal() { - if (!(await ensureGuestActionAllowed("waiter_call"))) { - return; - } - document.getElementById("waiterModal").classList.add("active"); - document.body.style.overflow = 'hidden'; -}; - -window.closeWaiterDialog = function () { - document.getElementById("waiterModal").classList.remove("active"); - document.body.style.overflow = ''; -}; - -window.confirmCallWaiter = async function () { - closeWaiterDialog(); - await callWaiter("order"); -}; - -window.proceedToBillPayment = async function () { - if (!(await ensureGuestActionAllowed("bill_request"))) { - return; - } - goToStep("stepPayment"); -}; - -window.openBillDialog = function () { - requireFullAccess("bill", () => { - openBillDialogInternal(); - }); -}; - -async function openBillDialogInternal() { - await refreshGuestPendingActions(); - trackEvent("bill_dialog_opened"); - billState = { payment: '', doc: '', nip: '', company: null, selectedBillId: null }; - document.getElementById("billModal").classList.add("active"); - document.body.style.overflow = 'hidden'; // Zablokuj scroll tła - - document.getElementById("billLoading").classList.remove("hidden"); - document.getElementById("billListContainer").classList.add("hidden"); - goToStep("stepBillList"); - - try { - const res = await fetch(`../api/bills.php?h=${encodeURIComponent(hashParam)}`); - const result = await res.json(); - - if (result.status === 'success' && result.data.length > 0) { - const bills = result.data; - cacheOpenBills(bills); - - if (bills.length === 1) { - showBillReview(bills[0]); - document.getElementById("btnBackToBills").style.display = 'none'; - } else { - renderBillList(bills); - document.getElementById("btnBackToBills").style.display = 'block'; - } - } else { - document.getElementById("billLoading").innerHTML = "Brak otwartych rachunków do opłacenia."; - } - } catch (err) { - document.getElementById("billLoading").innerHTML = "Błąd pobierania rachunków."; - } -}; - -function renderBillList(bills) { - document.getElementById("billLoading").classList.add("hidden"); - document.getElementById("billListContainer").classList.remove("hidden"); - - const container = document.getElementById("billListItems"); - container.innerHTML = ""; - - bills.forEach(b => { - const div = document.createElement("div"); - div.className = "option-card"; - div.style.flexDirection = "row"; - div.style.justifyContent = "space-between"; - div.style.padding = "15px"; - div.onclick = () => showBillReview(b); - - const numerFormat = b.numer ? `#${b.numer}` : "Rachunek"; - div.innerHTML = ` -
-
${numerFormat}
-
${b.opis}
-
-
${b.suma.toFixed(2)} PLN
- `; - container.appendChild(div); - }); -} - -window.goBackToBillList = function () { - goToStep("stepBillList"); -}; - -window.showBillReview = function (bill) { - billState.selectedBillId = bill.id; - - const content = document.getElementById("billReviewContent"); - content.innerHTML = ""; - - bill.pozycje.forEach(p => { - const div = document.createElement("div"); - div.style.display = "flex"; - div.style.justifyContent = "space-between"; - div.style.marginBottom = "8px"; - div.style.borderBottom = "1px solid rgba(255,255,255,0.05)"; - div.style.paddingBottom = "8px"; - - div.innerHTML = ` -
-
${p.nazwa}
-
${p.ilosc} x ${p.cena.toFixed(2)} PLN
-
-
${p.wartosc.toFixed(2)} PLN
- `; - content.appendChild(div); - }); - - document.getElementById("billTotalAmount").textContent = bill.suma.toFixed(2) + " PLN"; - goToStep("stepBillReview"); -}; - -window.closeBillDialog = function () { - document.getElementById("billModal").classList.remove("active"); - document.body.style.overflow = ''; // Odblokuj scroll tła -}; - -window.goToStep = function (stepId) { - document.querySelectorAll('.step').forEach(el => el.classList.remove('active')); - document.getElementById(stepId).classList.add('active'); -}; - -// --- SPA NAVIGATION LOGIC --- -function switchTabInternal(tabName) { - // 1. Zdejmij .active z widoków i ikonek nav - document.querySelectorAll('.view-section').forEach(el => el.classList.add('hidden')); - document.querySelectorAll('.view-section').forEach(el => el.classList.remove('active')); - - document.getElementById('navStatus').classList.remove('active'); - document.getElementById('navMenu').classList.remove('active'); - - const header = document.getElementById('mainHeader'); - const greetingBanner = document.getElementById('greetingBanner'); - - // 2. Nadaj .active wybranym elementom - if (tabName === 'status') { - trackEvent("view_status"); - const view = document.getElementById('statusView'); - view.classList.remove('hidden'); - view.classList.add('active'); - document.getElementById('navStatus').classList.add('active'); - if (header) header.style.display = ''; - if (greetingBanner && greetingBanner.innerHTML.trim() !== '') greetingBanner.style.display = ''; - } - else if (tabName === 'menu') { - trackEvent("view_menu", { flow: appAccessLevel === "menu" ? "menu_only" : "full_app" }); - const view = document.getElementById('menuView'); - view.classList.remove('hidden'); - view.classList.add('active'); - document.getElementById('navMenu').classList.add('active'); - if (header) header.style.display = 'none'; - if (greetingBanner) greetingBanner.style.display = 'none'; - } - window.scrollTo(0, 0); -} - -window.switchTab = function (tabName) { - if (tabName === "menu") { - switchTabInternal("menu"); - return; - } - if (tabName === "status") { - requireFullAccess("status", () => switchTabInternal("status")); - } -}; - -// --- MENU LOGIC --- -window.filterMenu = function () { - const query = document.getElementById('menuSearchInput').value.toLowerCase(); - const now = Date.now(); - if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) { - window.lastMenuSearchEventAt = now; - trackEvent("menu_search", { queryLength: query.length }); - } - const categories = document.querySelectorAll('.rm-category'); - - categories.forEach(category => { - let hasVisibleItems = false; - const items = category.querySelectorAll('.rmc-position'); - - items.forEach(item => { - const title = item.querySelector('.rmc-title h4').textContent.toLowerCase(); - if (title.includes(query)) { - item.style.display = ''; - hasVisibleItems = true; - } else { - item.style.display = 'none'; - } - }); - - if (hasVisibleItems) { - category.style.display = ''; - } else { - category.style.display = 'none'; - } - }); -}; - -window.showCategory = function (categoryId) { - document.querySelectorAll('.menu-categories-nav a').forEach(a => a.classList.remove('active')); - - const clickedLink = document.querySelector(`.menu-categories-nav a[data-category-badge="${categoryId}"]`); - if (clickedLink) { - clickedLink.classList.add('active'); - clickedLink.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); - } - - const categories = document.querySelectorAll('.rm-category'); - categories.forEach(cat => { - if (categoryId === 0) { - cat.style.display = ''; - } else { - const catId = parseInt(cat.getAttribute('data-cat-id'), 10); - if (catId === categoryId) { - cat.style.display = ''; - } else { - cat.style.display = 'none'; - } - } - }); -}; - -window.selectPayment = function (method) { - billState.payment = method; - goToStep("stepDocument"); -}; - -window.selectDocument = async function (docType) { - billState.doc = docType; - if (docType === 'paragon') { - if (!(await ensureGuestActionAllowed("bill_request"))) { - return; - } - const queued = await queueGuestAction("bill_request", buildBillRequestQueueMessage("paragon"), { - payment: billState.payment || null, - docType: "paragon", - }); - if (!queued.ok) { - if (queued.reason === "pending") { - showToast(guestActionBlockedMessage("bill_request")); - } else { - showToast("Nie udało się wysłać prośby o rachunek. Spróbuj ponownie za chwilę."); - } - return; - } - trackEvent("bill_request_sent", { docType: "paragon" }); - closeBillDialog(); - sendApiSimulated("CallWaiter_Bill", { table: tableParam, billId: billState.selectedBillId, payment: billState.payment, doc: 'paragon' }); - showToast("Kelner przyniesie paragon do opłacenia!"); - } else { - goToStep("stepNIP"); - document.getElementById("nipInput").value = ''; - setTimeout(() => document.getElementById("nipInput").focus(), 100); - } -}; - -window.fetchGUS = async function () { - const nip = document.getElementById("nipInput").value.replace(/[\s-]/g, ''); - if (nip.length < 10) { - alert("Wprowadź poprawny numer NIP."); - return; - } - const btn = document.getElementById("btnGUS"); - btn.textContent = "Szukam..."; - btn.disabled = true; - - try { - const apiKey = "d8bdc252-e0f1-4863-97a1-826faddbc49c"; - const response = await fetch(`https://api.magico.pro/v1/gus/${nip}?api_key=${apiKey}`); - const result = await response.json(); - - if (result.status && result.data) { - const data = result.data; - - let fullStreet = data.street || ''; - if (data.propertyNumber) fullStreet += ' ' + data.propertyNumber; - if (data.apartmentNumber) fullStreet += '/' + data.apartmentNumber; - - billState.nip = nip; - billState.company = { - name: data.name, - street: fullStreet.trim(), - zip: data.zipCode, - city: data.city, - nip: data.nip - }; - - document.getElementById("cmpName").value = billState.company.name; - document.getElementById("cmpStreet").value = billState.company.street; - document.getElementById("cmpZip").value = billState.company.zip; - document.getElementById("cmpCity").value = billState.company.city; - document.getElementById("cmpNip").value = "NIP: " + billState.company.nip; - - // reset do readonly - document.getElementById("cmpName").readOnly = true; - document.getElementById("cmpStreet").readOnly = true; - document.getElementById("cmpZip").readOnly = true; - document.getElementById("cmpCity").readOnly = true; - document.getElementById("btnEditCompany").textContent = "Popraw ręcznie"; - - goToStep("stepVerify"); - } else { - alert("Nie udało się pobrać danych z GUS dla podanego NIP-u."); - } - } catch (error) { - console.error("Błąd pobierania danych z GUS:", error); - alert("Błąd połączenia z API GUS."); - } finally { - btn.textContent = "Pobierz z GUS"; - btn.disabled = false; - } -}; - -// --- DYNAMIC MENU LOADING --- - -let itemModalKeys = []; -let itemModalIndex = -1; -let itemModalTouchStart = null; -let itemModalDragging = false; -let itemModalAnimating = false; - -function resetItemModalPane() { - const pane = document.getElementById("itemModalPane"); - if (!pane) return; - pane.classList.remove( - "is-dragging", - "is-exiting-left", - "is-exiting-right", - "is-entering-from-left", - "is-entering-from-right" - ); - pane.style.transform = ""; - pane.style.opacity = ""; -} - -function waitForPaneTransition(pane, timeoutMs = 400) { - return new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - pane.removeEventListener("transitionend", onEnd); - resolve(); - }; - const onEnd = (e) => { - if (e.target === pane) finish(); - }; - pane.addEventListener("transitionend", onEnd); - setTimeout(finish, timeoutMs); - }); -} - -function isValidMenuImageUrl(url) { - return typeof url === "string" && url.trim().length > 0; -} - -window.handleMenuImageError = function (imgEl) { - if (!imgEl) return; - imgEl.onerror = null; - imgEl.classList.add("hidden"); - const wrap = imgEl.closest(".rmc-image-wrap, .item-modal-image-wrap"); - const placeholder = wrap?.querySelector(".menu-image-placeholder"); - if (placeholder) placeholder.classList.remove("hidden"); -}; - -function applyMenuItemImage(imgEl, placeholderEl, url) { - if (!imgEl || !placeholderEl) return; - - if (!isValidMenuImageUrl(url)) { - imgEl.src = ""; - imgEl.classList.add("hidden"); - placeholderEl.classList.remove("hidden"); - return; - } - - imgEl.onload = () => { - imgEl.classList.remove("hidden"); - placeholderEl.classList.add("hidden"); - }; - imgEl.onerror = () => handleMenuImageError(imgEl); - imgEl.classList.remove("hidden"); - placeholderEl.classList.add("hidden"); - imgEl.src = url; -} - -function renderMenuListImage(url) { - const hasUrl = isValidMenuImageUrl(url); - const imgClass = hasUrl ? "rmc-image" : "rmc-image hidden"; - const placeholderClass = hasUrl ? "menu-image-placeholder hidden" : "menu-image-placeholder"; - const srcAttr = hasUrl ? ` src="${url}"` : ""; - const onerror = hasUrl ? ' onerror="handleMenuImageError(this)"' : ""; - - return `
`; -} - -function findMenuItem(categoryId, position) { - if (!window.menuDataRaw) return null; - for (const cat of window.menuDataRaw) { - for (const item of cat.items) { - if (item.categoryId == categoryId && item.position == position) { - return item; - } - } - } - return null; -} - -function buildVisibleMenuItemKeys() { - const keys = []; - document.querySelectorAll(".rmc-position").forEach((el) => { - if (el.style.display === "none") return; - const category = el.closest(".rm-category"); - if (category && category.style.display === "none") return; - keys.push({ - categoryId: el.getAttribute("data-category-id"), - position: el.getAttribute("data-position"), - }); - }); - return keys; -} - -function updateItemModalNavigation() { - const total = itemModalKeys.length; - const counter = document.getElementById("itemModalCounter"); - if (counter) counter.textContent = total > 1 ? `${itemModalIndex + 1} / ${total}` : ""; -} - -function populateItemModal(item) { - const imgEl = document.getElementById("itemModalImage"); - const placeholderEl = document.getElementById("itemModalImagePlaceholder"); - const titleEl = document.getElementById("itemModalTitle"); - const descEl = document.getElementById("itemModalDesc"); - const priceEl = document.getElementById("itemModalPrice"); - - applyMenuItemImage(imgEl, placeholderEl, item.image); - if (titleEl) titleEl.textContent = item.title; - if (descEl) descEl.textContent = item.description || ""; - if (priceEl) priceEl.textContent = item.price; - updateItemModalNavigation(); -} - -window.navigateItemModal = async function (delta) { - if (!itemModalKeys.length || !delta || itemModalAnimating) return; - const nextIndex = itemModalIndex + delta; - if (nextIndex < 0 || nextIndex >= itemModalKeys.length) return; - - const key = itemModalKeys[nextIndex]; - const item = findMenuItem(key.categoryId, key.position); - const pane = document.getElementById("itemModalPane"); - if (!item || !pane) return; - - itemModalAnimating = true; - resetItemModalPane(); - - const exitClass = delta > 0 ? "is-exiting-left" : "is-exiting-right"; - const enterClass = delta > 0 ? "is-entering-from-right" : "is-entering-from-left"; - - pane.classList.add(exitClass); - await waitForPaneTransition(pane); - - itemModalIndex = nextIndex; - populateItemModal(item); - - pane.classList.remove(exitClass); - pane.classList.add(enterClass); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - pane.classList.remove(enterClass); - }); - }); - - await waitForPaneTransition(pane); - itemModalAnimating = false; -}; - -function bindItemModalSwipe() { - const content = document.getElementById("itemModalContent"); - const pane = document.getElementById("itemModalPane"); - const modal = document.getElementById("itemModal"); - if (!content || !pane || !modal || content.dataset.swipeBound) return; - content.dataset.swipeBound = "1"; - - content.addEventListener( - "touchstart", - (e) => { - if (itemModalAnimating || itemModalKeys.length <= 1 || e.touches.length !== 1) return; - if (e.target.closest("button")) return; - const touch = e.touches[0]; - itemModalTouchStart = { x: touch.clientX, y: touch.clientY }; - itemModalDragging = false; - }, - { passive: true } - ); - - content.addEventListener( - "touchmove", - (e) => { - if (!itemModalTouchStart || itemModalAnimating || itemModalKeys.length <= 1) return; - const touch = e.touches[0]; - const dx = touch.clientX - itemModalTouchStart.x; - const dy = touch.clientY - itemModalTouchStart.y; - - if (!itemModalDragging) { - if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy)) { - itemModalDragging = true; - pane.classList.add("is-dragging"); - } else if (Math.abs(dy) > 12) { - itemModalTouchStart = null; - return; - } else { - return; - } - } - - let translateX = dx; - if (itemModalIndex <= 0 && dx > 0) translateX = dx * 0.3; - if (itemModalIndex >= itemModalKeys.length - 1 && dx < 0) translateX = dx * 0.3; - - pane.style.transform = `translateX(${translateX}px)`; - pane.style.opacity = String(1 - Math.min(Math.abs(translateX) / 320, 0.18)); - }, - { passive: true } - ); - - content.addEventListener( - "touchend", - (e) => { - if (!itemModalTouchStart) return; - const touch = e.changedTouches[0]; - const dx = touch.clientX - itemModalTouchStart.x; - const dy = touch.clientY - itemModalTouchStart.y; - const wasDragging = itemModalDragging; - - itemModalTouchStart = null; - itemModalDragging = false; - - if (!wasDragging) return; - - pane.classList.remove("is-dragging"); - pane.style.transform = ""; - pane.style.opacity = ""; - - if (Math.abs(dx) >= 50 && Math.abs(dx) > Math.abs(dy)) { - navigateItemModal(dx < 0 ? 1 : -1); - } - }, - { passive: true } - ); - - document.addEventListener("keydown", (e) => { - if (!modal.classList.contains("active")) return; - if (e.key === "ArrowRight") navigateItemModal(1); - if (e.key === "ArrowLeft") navigateItemModal(-1); - if (e.key === "Escape") closeItemModal(); - }); -} - -async function loadMenu() { - try { - 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; - const container = document.getElementById('menuContainer'); - if (!container) return; - - container.innerHTML = ''; - - menuData.forEach(category => { - const catId = category.items.length > 0 ? category.items[0].categoryId : ''; - - const catDiv = document.createElement('div'); - catDiv.className = 'rm-category'; - catDiv.setAttribute('data-cat-id', catId); - - let html = `
${category.categoryName}
-
`; - - category.items.forEach(item => { - html += ` -
- ${renderMenuListImage(item.image)} -
-

${item.title}${item.description}

-
-
${item.price}
-
- `; - }); - - html += `
`; - catDiv.innerHTML = html; - container.appendChild(catDiv); - }); - } catch (err) { - console.error('Błąd ładowania menu:', err); - const container = document.getElementById('menuContainer'); - if (container) { - container.innerHTML = '

Nie udało się załadować menu.

'; - } - } -} - -// Inicjalizacja ładowania menu loadMenu(); bindItemModalSwipe(); - -window.openItemModal = function(categoryId, position) { - itemModalKeys = buildVisibleMenuItemKeys(); - itemModalIndex = itemModalKeys.findIndex( - (key) => key.categoryId == categoryId && key.position == position - ); - - if (itemModalIndex < 0) { - itemModalKeys = [{ categoryId, position }]; - itemModalIndex = 0; - } - - const foundItem = findMenuItem(categoryId, position); - if (!foundItem) return; - - populateItemModal(foundItem); - resetItemModalPane(); - - const modal = document.getElementById('itemModal'); - if (modal) { - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; - } -}; - -window.closeItemModal = function() { - const modal = document.getElementById('itemModal'); - if (modal) { - modal.classList.remove('active'); - document.body.style.overflow = ''; - } - itemModalTouchStart = null; - itemModalDragging = false; - itemModalAnimating = false; - resetItemModalPane(); -}; - -window.editCompanyData = function () { - const n = document.getElementById("cmpName"); - const s = document.getElementById("cmpStreet"); - const z = document.getElementById("cmpZip"); - const c = document.getElementById("cmpCity"); - const btn = document.getElementById("btnEditCompany"); - - if (n.readOnly) { - n.readOnly = false; - s.readOnly = false; - z.readOnly = false; - c.readOnly = false; - n.focus(); - btn.textContent = "Zakończ edycję"; - } else { - n.readOnly = true; - s.readOnly = true; - z.readOnly = true; - c.readOnly = true; - btn.textContent = "Popraw ręcznie"; - } -}; - -window.confirmInvoice = async function () { - billState.company.name = document.getElementById("cmpName").value; - billState.company.street = document.getElementById("cmpStreet").value; - billState.company.zip = document.getElementById("cmpZip").value; - billState.company.city = document.getElementById("cmpCity").value; - - if (!(await ensureGuestActionAllowed("bill_request"))) { - return; - } - - const queued = await queueGuestAction("bill_request", buildBillRequestQueueMessage("faktura"), { - payment: billState.payment || null, - docType: "faktura", - nip: billState.nip || null, - company: billState.company || null, - }); - - if (!queued.ok) { - if (queued.reason === "pending") { - showToast(guestActionBlockedMessage("bill_request")); - } else { - showToast("Nie udało się wysłać prośby o rachunek. Spróbuj ponownie za chwilę."); - } - return; - } - - closeBillDialog(); - trackEvent("bill_request_sent", { docType: "faktura" }); - sendApiSimulated("CallWaiter_Bill", { - table: tableParam, - billId: billState.selectedBillId, - payment: billState.payment, - doc: 'faktura', - nip: billState.nip, - company: billState.company - }); - showToast("Dziękujemy! Prośba o fakturę została wysłana."); -}; - -// --- GEOLOCATION LOGIC --- -// 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; - const p1 = lat1 * Math.PI / 180; - const p2 = lat2 * Math.PI / 180; - const dp = (lat2 - lat1) * Math.PI / 180; - const dl = (lon2 - lon1) * Math.PI / 180; - - const a = Math.sin(dp / 2) * Math.sin(dp / 2) + - Math.cos(p1) * Math.cos(p2) * - Math.sin(dl / 2) * Math.sin(dl / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - 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(); - document.getElementById("geoScreen").classList.add("hidden"); - document.getElementById("loadingScreen").classList.remove("hidden"); - trackEvent("session_start", { flow: "start_app" }); - - initUserProfile(); - fetchOrders(); - prefetchOpenBills(); - refreshGuestPendingActions(); - startGuestPendingPoll(); - if (!window.ordersInterval) { - window.ordersInterval = setInterval(fetchOrders, 10000); - } - - // Fallback: If no data after 25s, show empty state anyway - setTimeout(() => { - if (!document.getElementById("loadingScreen").classList.contains("hidden")) { - updateUI([]); - } - }, 25000); -} - -function showGeoConsentScreen() { - const geoScreen = document.getElementById("geoScreen"); - const loadingScreen = document.getElementById("loadingScreen"); - const geoActionBtn = document.getElementById("geoActionBtn"); - - loadingScreen.classList.add("hidden"); - geoScreen.classList.remove("hidden"); - - setGeoLead(GEO_DEFAULT_LEAD); - setGeoStatus(""); - hideGeoInstructions(); - - if (geoActionBtn) { - setGeoActionBusy(false); - setGeoActionLabel("Zgoda, sprawdź lokalizację"); - } - configureGeoSecondaryButton("menu_only"); -} - -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() { - const bypassHosts = ['82.160.190.247']; - return bypassHosts.includes(window.location.hostname); -} - -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 geoActionBtn = document.getElementById("geoActionBtn"); - - 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."); - bypassGeolocation("local_http", { host: window.location.hostname }); - return; - } - - loadingScreen?.classList.add("hidden"); - geoScreen?.classList.remove("hidden"); - configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only"); - - 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; - } - - if (!navigator.geolocation) { - setGeoLead(GEO_DEFAULT_LEAD); - setGeoStatus("Twoja przeglądarka nie wspiera geolokalizacji.", { error: true }); - hideGeoInstructions(); - setGeoActionBusy(false); - return; - } - - setGeoLead(GEO_DEFAULT_LEAD); - setGeoStatus("Sprawdzamy Twoją lokalizację…", { info: true }); - hideGeoInstructions(); - setGeoActionBusy(true); - - const permissionState = await queryGeolocationPermissionState(); - if (permissionState === "denied") { - showGeoPermissionBlockedState(); - return; - } - - trackEvent("geo_check_started"); - - 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()) { - bypassGeolocation("trusted_host", { host: window.location.hostname }); -} else { - bootstrapGeolocation(); -} \ No newline at end of file +startGeoBootstrap(); diff --git a/public/assets/js/modules/access.js b/public/assets/js/modules/access.js new file mode 100644 index 0000000..4a0797c --- /dev/null +++ b/public/assets/js/modules/access.js @@ -0,0 +1,114 @@ +import { trackEvent } from "./analytics.js"; +import { + getAppAccessLevel, + getPendingProtectedAction, + setPendingProtectedAction, +} from "./state.js"; + +/** @type {((action: string) => void) | null} */ +let geoGateHandler = null; + +/** @type {{ waiter?: () => void, bill?: () => void }} */ +let protectedActionHandlers = {}; + +export function setGeoGateHandler(handler) { + geoGateHandler = handler; +} + +export function setProtectedActionHandlers(handlers) { + protectedActionHandlers = handlers || {}; +} + +export function updateNavAccessState() { + const locked = getAppAccessLevel() === "menu"; + ["navStatus", "navWaiter", "navBill"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.classList.toggle("nav-locked", locked); + }); + const banner = document.getElementById("menuOnlyBanner"); + if (banner) { + banner.classList.remove("hidden"); + banner.classList.toggle("is-hidden", getAppAccessLevel() !== "menu"); + } +} + +export function showBottomNav() { + const bottomNav = document.getElementById("bottomNav"); + if (bottomNav) bottomNav.style.display = ""; + updateNavAccessState(); +} + +export function requireFullAccess(action, onGranted) { + if (getAppAccessLevel() === "full") { + onGranted(); + return; + } + setPendingProtectedAction(action); + trackEvent("geo_gate_prompted", { action }); + if (getAppAccessLevel() === "menu") { + trackEvent("geo_retry_from_menu", { action }); + } + geoGateHandler?.(action); +} + +export function runPendingProtectedAction() { + const action = getPendingProtectedAction(); + setPendingProtectedAction(null); + if (!action) return; + + setTimeout(() => { + if (action === "status") switchTabInternal("status"); + else if (action === "waiter") protectedActionHandlers.waiter?.(); + else if (action === "bill") protectedActionHandlers.bill?.(); + }, 150); +} + +export function switchTabInternal(tabName) { + // 1. Zdejmij .active z widoków i ikonek nav + document.querySelectorAll(".view-section").forEach((el) => el.classList.add("hidden")); + document.querySelectorAll(".view-section").forEach((el) => el.classList.remove("active")); + + document.getElementById("navStatus").classList.remove("active"); + document.getElementById("navMenu").classList.remove("active"); + + const header = document.getElementById("mainHeader"); + const greetingBanner = document.getElementById("greetingBanner"); + + // 2. Nadaj .active wybranym elementom + if (tabName === "status") { + trackEvent("view_status"); + const view = document.getElementById("statusView"); + view.classList.remove("hidden"); + view.classList.add("active"); + document.getElementById("navStatus").classList.add("active"); + if (header) header.style.display = ""; + if (greetingBanner && greetingBanner.innerHTML.trim() !== "") greetingBanner.style.display = ""; + } else if (tabName === "menu") { + trackEvent("view_menu", { flow: getAppAccessLevel() === "menu" ? "menu_only" : "full_app" }); + const view = document.getElementById("menuView"); + view.classList.remove("hidden"); + view.classList.add("active"); + document.getElementById("navMenu").classList.add("active"); + if (header) header.style.display = "none"; + if (greetingBanner) greetingBanner.style.display = "none"; + } + window.scrollTo(0, 0); +} + +export function switchTab(tabName) { + if (tabName === "menu") { + switchTabInternal("menu"); + return; + } + if (tabName === "status") { + requireFullAccess("status", () => switchTabInternal("status")); + } +} + +export function promptGeoForFullAccess() { + const pending = getPendingProtectedAction() || "status"; + setPendingProtectedAction(pending); + trackEvent("geo_gate_prompted", { action: pending, source: "menu_banner" }); + trackEvent("geo_retry_from_menu", { action: pending, source: "menu_banner" }); + geoGateHandler?.(pending); +} diff --git a/public/assets/js/modules/analytics.js b/public/assets/js/modules/analytics.js new file mode 100644 index 0000000..5dd7c50 --- /dev/null +++ b/public/assets/js/modules/analytics.js @@ -0,0 +1,93 @@ +import { endpoints } from "./config.js"; +import { + getAnalyticsSessionId, + getHashParam, + getIsStaffPreview, + getTableParam, + setAnalyticsSessionId, +} from "./state.js"; + +const analyticsSessionKey = "karczma_analytics_session_id"; + +function getOrCreateAnalyticsSessionId() { + let existing = localStorage.getItem(analyticsSessionKey); + if (existing) return existing; + + let newId = ""; + if (window.crypto && crypto.randomUUID) { + newId = crypto.randomUUID(); + } else { + newId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + } + localStorage.setItem(analyticsSessionKey, newId); + return newId; +} + +setAnalyticsSessionId(getOrCreateAnalyticsSessionId()); + +function detectDeviceType() { + const ua = navigator.userAgent || ""; + if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)) return "ios"; + if (/Android/i.test(ua)) return "android"; + return "other"; +} + +function detectBrowser() { + const ua = navigator.userAgent || ""; + if (/Edg\//.test(ua)) return "edge"; + if (/OPR\//.test(ua)) return "opera"; + if (/Chrome\//.test(ua)) return "chrome"; + if (/Safari\//.test(ua)) return "safari"; + if (/Firefox\//.test(ua)) return "firefox"; + return "other"; +} + +function deriveZoneFromTable(tableValue) { + const raw = String(tableValue || "").trim().toLowerCase(); + if (!raw) return null; + + if (raw.startsWith("t") || raw.includes("taras")) return "taras"; + if (raw.startsWith("k") || raw.includes("karczma")) return "karczma"; + return null; +} + +export function trackEvent(eventName, payload = {}) { + if (getIsStaffPreview()) { + return; + } + + const tableParam = getTableParam(); + const hashParam = getHashParam(); + + const body = { + eventName, + sessionId: getAnalyticsSessionId(), + tableId: tableParam || null, + zone: deriveZoneFromTable(tableParam), + qrHash: hashParam || null, + deviceType: detectDeviceType(), + browser: detectBrowser(), + payload + }; + + const bodyString = JSON.stringify(body); + + try { + if (navigator.sendBeacon) { + const blob = new Blob([bodyString], { type: "application/json" }); + navigator.sendBeacon(endpoints.analytics, blob); + return; + } + } catch { + // fallback to fetch below + } + + fetch(endpoints.analytics, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: bodyString, + keepalive: true + }).catch(() => { + // best effort - ignore analytics errors + }); +} diff --git a/public/assets/js/modules/bill.js b/public/assets/js/modules/bill.js new file mode 100644 index 0000000..b8d2458 --- /dev/null +++ b/public/assets/js/modules/bill.js @@ -0,0 +1,353 @@ +import { endpoints } from "./config.js"; +import { trackEvent } from "./analytics.js"; +import { requireFullAccess } from "./access.js"; +import { + buildBillRequestQueueMessage, + buildWaiterCallQueueMessage, + cacheOpenBills, + ensureGuestActionAllowed, + guestActionBlockedMessage, + queueGuestAction, + refreshGuestPendingActions, +} from "./queue.js"; +import { showToast } from "./toast.js"; +import { + getBillState, + getHashParam, + getTableParam, + setBillState, +} from "./state.js"; + +function sendApiSimulated(actionName, details) { + console.log(`[SYMULACJA API] Akcja: ${actionName}`, details); + // Przykładowe wysłanie docelowo: + // if (window.socket && window.socket.readyState === WebSocket.OPEN) { + // window.socket.send(JSON.stringify({ action: "sendUpstream", payload: { type: actionName, table: tableParam, ...details } })); + // } +} + +export async function callWaiter(type) { + if (type !== "order") return; + + if (!(await ensureGuestActionAllowed("waiter_call"))) { + return; + } + + const queued = await queueGuestAction("waiter_call", buildWaiterCallQueueMessage(), { + waiterType: "order", + }); + + if (!queued.ok) { + if (queued.reason === "pending") { + showToast(guestActionBlockedMessage("waiter_call")); + } else { + showToast("Nie udało się wysłać wezwania. Spróbuj ponownie za chwilę."); + } + return; + } + + trackEvent("waiter_call_requested", { waiterType: "order" }); + sendApiSimulated("CallWaiter_Order", { table: getTableParam() }); + showToast("Kelner wkrótce do Ciebie podejdzie!"); +} + +export function openWaiterDialog() { + requireFullAccess("waiter", () => { + openWaiterDialogInternal(); + }); +} + +export async function openWaiterDialogInternal() { + if (!(await ensureGuestActionAllowed("waiter_call"))) { + return; + } + document.getElementById("waiterModal").classList.add("active"); + document.body.style.overflow = "hidden"; +} + +export function closeWaiterDialog() { + document.getElementById("waiterModal").classList.remove("active"); + document.body.style.overflow = ""; +} + +export async function confirmCallWaiter() { + closeWaiterDialog(); + await callWaiter("order"); +} + +export async function proceedToBillPayment() { + if (!(await ensureGuestActionAllowed("bill_request"))) { + return; + } + goToStep("stepPayment"); +} + +export function openBillDialog() { + requireFullAccess("bill", () => { + openBillDialogInternal(); + }); +} + +export async function openBillDialogInternal() { + await refreshGuestPendingActions(); + trackEvent("bill_dialog_opened"); + setBillState({ payment: "", doc: "", nip: "", company: null, selectedBillId: null }); + document.getElementById("billModal").classList.add("active"); + document.body.style.overflow = "hidden"; // Zablokuj scroll tła + + document.getElementById("billLoading").classList.remove("hidden"); + document.getElementById("billListContainer").classList.add("hidden"); + goToStep("stepBillList"); + + try { + const res = await fetch(`${endpoints.bills}?h=${encodeURIComponent(getHashParam())}`); + const result = await res.json(); + + if (result.status === "success" && result.data.length > 0) { + const bills = result.data; + cacheOpenBills(bills); + + if (bills.length === 1) { + showBillReview(bills[0]); + document.getElementById("btnBackToBills").style.display = "none"; + } else { + renderBillList(bills); + document.getElementById("btnBackToBills").style.display = "block"; + } + } else { + document.getElementById("billLoading").innerHTML = "Brak otwartych rachunków do opłacenia."; + } + } catch (err) { + document.getElementById("billLoading").innerHTML = "Błąd pobierania rachunków."; + } +} + +function renderBillList(bills) { + document.getElementById("billLoading").classList.add("hidden"); + document.getElementById("billListContainer").classList.remove("hidden"); + + const container = document.getElementById("billListItems"); + container.innerHTML = ""; + + bills.forEach((b) => { + const div = document.createElement("div"); + div.className = "option-card"; + div.style.flexDirection = "row"; + div.style.justifyContent = "space-between"; + div.style.padding = "15px"; + div.onclick = () => showBillReview(b); + + const numerFormat = b.numer ? `#${b.numer}` : "Rachunek"; + div.innerHTML = ` +
+
${numerFormat}
+
${b.opis}
+
+
${b.suma.toFixed(2)} PLN
+ `; + container.appendChild(div); + }); +} + +export function goBackToBillList() { + goToStep("stepBillList"); +} + +export function showBillReview(bill) { + const billState = getBillState(); + billState.selectedBillId = bill.id; + + const content = document.getElementById("billReviewContent"); + content.innerHTML = ""; + + bill.pozycje.forEach((p) => { + const div = document.createElement("div"); + div.style.display = "flex"; + div.style.justifyContent = "space-between"; + div.style.marginBottom = "8px"; + div.style.borderBottom = "1px solid rgba(255,255,255,0.05)"; + div.style.paddingBottom = "8px"; + + div.innerHTML = ` +
+
${p.nazwa}
+
${p.ilosc} x ${p.cena.toFixed(2)} PLN
+
+
${p.wartosc.toFixed(2)} PLN
+ `; + content.appendChild(div); + }); + + document.getElementById("billTotalAmount").textContent = bill.suma.toFixed(2) + " PLN"; + goToStep("stepBillReview"); +} + +export function closeBillDialog() { + document.getElementById("billModal").classList.remove("active"); + document.body.style.overflow = ""; // Odblokuj scroll tła +} + +export function goToStep(stepId) { + document.querySelectorAll(".step").forEach((el) => el.classList.remove("active")); + document.getElementById(stepId).classList.add("active"); +} + +export function selectPayment(method) { + getBillState().payment = method; + goToStep("stepDocument"); +} + +export async function selectDocument(docType) { + const billState = getBillState(); + billState.doc = docType; + if (docType === "paragon") { + if (!(await ensureGuestActionAllowed("bill_request"))) { + return; + } + const queued = await queueGuestAction("bill_request", buildBillRequestQueueMessage("paragon"), { + payment: billState.payment || null, + docType: "paragon", + }); + if (!queued.ok) { + if (queued.reason === "pending") { + showToast(guestActionBlockedMessage("bill_request")); + } else { + showToast("Nie udało się wysłać prośby o rachunek. Spróbuj ponownie za chwilę."); + } + return; + } + trackEvent("bill_request_sent", { docType: "paragon" }); + closeBillDialog(); + sendApiSimulated("CallWaiter_Bill", { + table: getTableParam(), + billId: billState.selectedBillId, + payment: billState.payment, + doc: "paragon", + }); + showToast("Kelner przyniesie paragon do opłacenia!"); + } else { + goToStep("stepNIP"); + document.getElementById("nipInput").value = ""; + setTimeout(() => document.getElementById("nipInput").focus(), 100); + } +} + +export async function fetchGUS() { + const nip = document.getElementById("nipInput").value.replace(/[\s-]/g, ""); + if (nip.length < 10) { + alert("Wprowadź poprawny numer NIP."); + return; + } + const btn = document.getElementById("btnGUS"); + btn.textContent = "Szukam..."; + btn.disabled = true; + + try { + const response = await fetch(`${endpoints.gusLookup}?nip=${encodeURIComponent(nip)}`); + const result = await response.json(); + + if (result.status && result.data) { + const data = result.data; + + let fullStreet = data.street || ""; + if (data.propertyNumber) fullStreet += " " + data.propertyNumber; + if (data.apartmentNumber) fullStreet += "/" + data.apartmentNumber; + + const billState = getBillState(); + billState.nip = nip; + billState.company = { + name: data.name, + street: fullStreet.trim(), + zip: data.zipCode, + city: data.city, + nip: data.nip, + }; + + document.getElementById("cmpName").value = billState.company.name; + document.getElementById("cmpStreet").value = billState.company.street; + document.getElementById("cmpZip").value = billState.company.zip; + document.getElementById("cmpCity").value = billState.company.city; + document.getElementById("cmpNip").value = "NIP: " + billState.company.nip; + + // reset do readonly + document.getElementById("cmpName").readOnly = true; + document.getElementById("cmpStreet").readOnly = true; + document.getElementById("cmpZip").readOnly = true; + document.getElementById("cmpCity").readOnly = true; + document.getElementById("btnEditCompany").textContent = "Popraw ręcznie"; + + goToStep("stepVerify"); + } else { + alert("Nie udało się pobrać danych z GUS dla podanego NIP-u."); + } + } catch (error) { + console.error("Błąd pobierania danych z GUS:", error); + alert("Błąd połączenia z API GUS."); + } finally { + btn.textContent = "Pobierz z GUS"; + btn.disabled = false; + } +} + +export function editCompanyData() { + const n = document.getElementById("cmpName"); + const s = document.getElementById("cmpStreet"); + const z = document.getElementById("cmpZip"); + const c = document.getElementById("cmpCity"); + const btn = document.getElementById("btnEditCompany"); + + if (n.readOnly) { + n.readOnly = false; + s.readOnly = false; + z.readOnly = false; + c.readOnly = false; + n.focus(); + btn.textContent = "Zakończ edycję"; + } else { + n.readOnly = true; + s.readOnly = true; + z.readOnly = true; + c.readOnly = true; + btn.textContent = "Popraw ręcznie"; + } +} + +export async function confirmInvoice() { + const billState = getBillState(); + billState.company.name = document.getElementById("cmpName").value; + billState.company.street = document.getElementById("cmpStreet").value; + billState.company.zip = document.getElementById("cmpZip").value; + billState.company.city = document.getElementById("cmpCity").value; + + if (!(await ensureGuestActionAllowed("bill_request"))) { + return; + } + + const queued = await queueGuestAction("bill_request", buildBillRequestQueueMessage("faktura"), { + payment: billState.payment || null, + docType: "faktura", + nip: billState.nip || null, + company: billState.company || null, + }); + + if (!queued.ok) { + if (queued.reason === "pending") { + showToast(guestActionBlockedMessage("bill_request")); + } else { + showToast("Nie udało się wysłać prośby o rachunek. Spróbuj ponownie za chwilę."); + } + return; + } + + closeBillDialog(); + trackEvent("bill_request_sent", { docType: "faktura" }); + sendApiSimulated("CallWaiter_Bill", { + table: getTableParam(), + billId: billState.selectedBillId, + payment: billState.payment, + doc: "faktura", + nip: billState.nip, + company: billState.company, + }); + showToast("Dziękujemy! Prośba o fakturę została wysłana."); +} diff --git a/public/assets/js/modules/config.js b/public/assets/js/modules/config.js new file mode 100644 index 0000000..d9fa168 --- /dev/null +++ b/public/assets/js/modules/config.js @@ -0,0 +1,19 @@ +const cfg = window.APP_CONFIG || {}; + +export const geoBypassHosts = Array.isArray(cfg.geoBypassHosts) + ? cfg.geoBypassHosts + : []; + +export const endpoints = { + analytics: cfg.endpoints?.analytics || "../api/analytics.php", + guestActionQueue: cfg.endpoints?.guestActionQueue || "../api/guest_action_queue.php", + geoBypass: cfg.endpoints?.geoBypass || "../api/geo_bypass.php", + gusLookup: cfg.endpoints?.gusLookup || "../api/gus_lookup.php", + kds: cfg.endpoints?.kds || "../api/kds.php", + bills: cfg.endpoints?.bills || "../api/bills.php", +}; + +export const loaderMinMs = Number(cfg.loaderMinMs) || 10_000; + +export const MENU_ASSET_VERSION = + window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1"; diff --git a/public/assets/js/modules/geo.js b/public/assets/js/modules/geo.js new file mode 100644 index 0000000..54bd03b --- /dev/null +++ b/public/assets/js/modules/geo.js @@ -0,0 +1,639 @@ +import { endpoints, geoBypassHosts } from "./config.js"; +import { trackEvent } from "./analytics.js"; +import { + runPendingProtectedAction, + setGeoGateHandler, + showBottomNav, + switchTabInternal, + updateNavAccessState, +} from "./access.js"; +import { + prefetchOpenBills, + refreshGuestPendingActions, + startGuestPendingPoll, +} from "./queue.js"; +import { fetchOrders, resolveTableLabel, updateUI } from "./orders.js"; +import { + getAppAccessLevel, + setAppAccessLevel, + setPendingProtectedAction, +} from "./state.js"; + +// USER PROFILE LOGIC +const userProfileKey = "karczma_user_profile"; +const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months + +export function initUserProfile() { + return; // Funkcja tymczasowo wyłączona + try { + const raw = localStorage.getItem(userProfileKey); + let profile = null; + if (raw) { + profile = JSON.parse(raw); + } + + const now = Date.now(); + + // Check if profile exists and is valid + if (profile) { + if (profile.declined) { + // User declined in the past, don't ask again. + return; + } + + // If expired, maybe we want to ask again or we could just keep it if they didn't decline. + // The user said: "trzymamy przez wiele miesięcy (odnawiamy datę przy każdej wizycie)" + if (now - profile.lastVisit > USER_PROFILE_EXPIRE_MS) { + // Profile expired. Ask again. + showNameDialog(); + } else { + // Profile valid, renew date and show greeting + profile.lastVisit = now; + localStorage.setItem(userProfileKey, JSON.stringify(profile)); + showGreeting(profile.name, profile.firstVisit || profile.lastVisit); + } + } else { + // No profile, ask for name + showNameDialog(); + } + } catch (err) { + // If error parsing, ask again + showNameDialog(); + } +} + +function showNameDialog() { + const modal = document.getElementById("nameModal"); + if (modal) { + modal.classList.add("active"); + document.body.style.overflow = "hidden"; + } +} + +function hideNameDialog() { + const modal = document.getElementById("nameModal"); + if (modal) { + modal.classList.remove("active"); + document.body.style.overflow = ""; + } +} + +export function saveUserName() { + const input = document.getElementById("userNameInput").value.trim(); + if (input) { + const now = Date.now(); + const profile = { name: input, firstVisit: now, lastVisit: now, declined: false }; + localStorage.setItem(userProfileKey, JSON.stringify(profile)); + hideNameDialog(); + showGreeting(profile.name, profile.firstVisit); + } +} + +export function declineUserName() { + const profile = { name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true }; + localStorage.setItem(userProfileKey, JSON.stringify(profile)); + hideNameDialog(); +} + +function showGreeting(name, firstVisitTime) { + const banner = document.getElementById("greetingBanner"); + if (banner && name) { + const isToday = new Date(firstVisitTime).toDateString() === new Date().toDateString(); + + if (isToday) { + banner.innerHTML = `Cześć ${name}, życzymy pysznego posiłku!`; + } else { + banner.innerHTML = `Witaj ${name}, super że do nas wracasz!`; + } + banner.style.display = "block"; + } +} + +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"; + +export function handleGeoMenuClick() { + if (geoMenuButtonMode === "back_to_menu") { + document.getElementById("geoScreen")?.classList.add("hidden"); + return; + } + enterMenuOnlyMode(); +} + +export function retryGeolocation() { + if (shouldBypassGeolocationHost()) { + bypassGeolocation("trusted_host", { host: window.location.hostname }); + return; + } + + initGeolocationAfterBypassChecks({ userInitiated: true }).catch((err) => { + console.error("[GEO] retry failed", err); + showGeoPermissionBlockedState(); + }); +} + +export 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 = ""; + if (mainEl) mainEl.textContent = "Wróć do menu"; + if (subEl) { + subEl.textContent = ""; + subEl.style.display = "none"; + } + return; + } + + menuOnlyBtn.style.display = ""; + if (mainEl) mainEl.textContent = "Przejdź do menu"; + if (subEl) { + subEl.textContent = "bez lokalizacji"; + subEl.style.display = ""; + } +} + +export function showGeoGateForAction(action) { + const geoScreen = document.getElementById("geoScreen"); + const loadingScreen = document.getElementById("loadingScreen"); + const geoActionBtn = document.getElementById("geoActionBtn"); + + if (loadingScreen) loadingScreen.classList.add("hidden"); + if (geoScreen) geoScreen.classList.remove("hidden"); + + const feature = GEO_GATE_LABELS[action] || "tę funkcję"; + setGeoLead(`Menu masz już otwarte. Aby skorzystać z ${feature}, potwierdź krótko, że jesteś w restauracji.`); + setGeoStatus(""); + hideGeoInstructions(); + if (geoActionBtn) { + setGeoActionBusy(false); + setGeoActionLabel("Sprawdź lokalizację"); + } + configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"); +} + +setGeoGateHandler(showGeoGateForAction); + +export function unlockFullApp() { + setAppAccessLevel("full"); + updateNavAccessState(); + document.getElementById("geoScreen")?.classList.add("hidden"); + + const loaderVisible = !document.getElementById("loadingScreen")?.classList.contains("hidden"); + const alreadyStarted = !!window.ordersInterval; + + if (alreadyStarted && !loaderVisible) { + trackEvent("session_start", { flow: "unlock_from_menu" }); + initUserProfile(); + fetchOrders(); + prefetchOpenBills(); + refreshGuestPendingActions(); + startGuestPendingPoll(); + runPendingProtectedAction(); + return; + } + + startApp(); +} + +export function enterMenuOnlyMode() { + trackEvent("menu_only_entered"); + setAppAccessLevel("menu"); + setPendingProtectedAction(null); + + document.getElementById("geoScreen")?.classList.add("hidden"); + document.getElementById("loadingScreen")?.classList.add("hidden"); + showBottomNav(); + resolveTableLabel(); + switchTabInternal("menu"); +} + +// --- GEOLOCATION LOGIC --- +// 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; + const p1 = (lat1 * Math.PI) / 180; + const p2 = (lat2 * Math.PI) / 180; + const dp = ((lat2 - lat1) * Math.PI) / 180; + const dl = ((lon2 - lon1) * Math.PI) / 180; + + const a = + Math.sin(dp / 2) * Math.sin(dp / 2) + + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) * Math.sin(dl / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + 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); + }); +} + +export function startApp() { + setAppAccessLevel("full"); + updateNavAccessState(); + document.getElementById("geoScreen").classList.add("hidden"); + document.getElementById("loadingScreen").classList.remove("hidden"); + trackEvent("session_start", { flow: "start_app" }); + + initUserProfile(); + fetchOrders(); + prefetchOpenBills(); + refreshGuestPendingActions(); + startGuestPendingPoll(); + if (!window.ordersInterval) { + window.ordersInterval = setInterval(fetchOrders, 10000); + } + + // Fallback: If no data after 25s, show empty state anyway + setTimeout(() => { + if (!document.getElementById("loadingScreen").classList.contains("hidden")) { + updateUI([]); + } + }, 25000); +} + +function showGeoConsentScreen() { + const geoScreen = document.getElementById("geoScreen"); + const loadingScreen = document.getElementById("loadingScreen"); + const geoActionBtn = document.getElementById("geoActionBtn"); + + loadingScreen.classList.add("hidden"); + geoScreen.classList.remove("hidden"); + + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus(""); + hideGeoInstructions(); + + if (geoActionBtn) { + setGeoActionBusy(false); + setGeoActionLabel("Zgoda, sprawdź lokalizację"); + } + configureGeoSecondaryButton("menu_only"); +} + +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(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"); +} + +export function shouldBypassGeolocationHost() { + return geoBypassHosts.includes(window.location.hostname); +} + +async function checkGeoBypassByClientIp() { + try { + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), 4000) : null; + + const res = await fetch(endpoints.geoBypass, { + 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 (getAppAccessLevel() === "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"; + } +} + +export async function bootstrapGeolocation() { + if (shouldBypassGeolocationHost()) { + bypassGeolocation("trusted_host", { host: window.location.hostname }); + return; + } + + if (await checkGeoBypassByClientIp()) { + bypassGeolocation("trusted_ip"); + return; + } + + showGeoConsentScreen(); +} + +export function initGeolocation() { + 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(); + }); +} + +export async function initGeolocationAfterBypassChecks(options = {}) { + const geoScreen = document.getElementById("geoScreen"); + const loadingScreen = document.getElementById("loadingScreen"); + + const localBypassHosts = ["localhost", "127.0.0.1", "192.168.20.84"]; + if (window.location.protocol === "http:" && localBypassHosts.includes(window.location.hostname)) { + console.warn("Bypassing geolocation on local HTTP environment."); + bypassGeolocation("local_http", { host: window.location.hostname }); + return; + } + + loadingScreen?.classList.add("hidden"); + geoScreen?.classList.remove("hidden"); + configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"); + + 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; + } + + if (!navigator.geolocation) { + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus("Twoja przeglądarka nie wspiera geolokalizacji.", { error: true }); + hideGeoInstructions(); + setGeoActionBusy(false); + return; + } + + setGeoLead(GEO_DEFAULT_LEAD); + setGeoStatus("Sprawdzamy Twoją lokalizację…", { info: true }); + hideGeoInstructions(); + setGeoActionBusy(true); + + const permissionState = await queryGeolocationPermissionState(); + if (permissionState === "denied") { + showGeoPermissionBlockedState(); + return; + } + + trackEvent("geo_check_started"); + + 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(getAppAccessLevel() === "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(getAppAccessLevel() === "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."); + } + } +} + +export function startGeoBootstrap() { + bindGeoScreenButtons(); + + if (shouldBypassGeolocationHost()) { + bypassGeolocation("trusted_host", { host: window.location.hostname }); + } else { + bootstrapGeolocation(); + } +} diff --git a/public/assets/js/modules/menu.js b/public/assets/js/modules/menu.js new file mode 100644 index 0000000..29dc919 --- /dev/null +++ b/public/assets/js/modules/menu.js @@ -0,0 +1,382 @@ +import { MENU_ASSET_VERSION } from "./config.js"; +import { trackEvent } from "./analytics.js"; + +let itemModalKeys = []; +let itemModalIndex = -1; +let itemModalTouchStart = null; +let itemModalDragging = false; +let itemModalAnimating = false; + +function resetItemModalPane() { + const pane = document.getElementById("itemModalPane"); + if (!pane) return; + pane.classList.remove( + "is-dragging", + "is-exiting-left", + "is-exiting-right", + "is-entering-from-left", + "is-entering-from-right" + ); + pane.style.transform = ""; + pane.style.opacity = ""; +} + +function waitForPaneTransition(pane, timeoutMs = 400) { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + pane.removeEventListener("transitionend", onEnd); + resolve(); + }; + const onEnd = (e) => { + if (e.target === pane) finish(); + }; + pane.addEventListener("transitionend", onEnd); + setTimeout(finish, timeoutMs); + }); +} + +function isValidMenuImageUrl(url) { + return typeof url === "string" && url.trim().length > 0; +} + +export function handleMenuImageError(imgEl) { + if (!imgEl) return; + imgEl.onerror = null; + imgEl.classList.add("hidden"); + const wrap = imgEl.closest(".rmc-image-wrap, .item-modal-image-wrap"); + const placeholder = wrap?.querySelector(".menu-image-placeholder"); + if (placeholder) placeholder.classList.remove("hidden"); +} + +function applyMenuItemImage(imgEl, placeholderEl, url) { + if (!imgEl || !placeholderEl) return; + + if (!isValidMenuImageUrl(url)) { + imgEl.src = ""; + imgEl.classList.add("hidden"); + placeholderEl.classList.remove("hidden"); + return; + } + + imgEl.onload = () => { + imgEl.classList.remove("hidden"); + placeholderEl.classList.add("hidden"); + }; + imgEl.onerror = () => handleMenuImageError(imgEl); + imgEl.classList.remove("hidden"); + placeholderEl.classList.add("hidden"); + imgEl.src = url; +} + +function renderMenuListImage(url) { + const hasUrl = isValidMenuImageUrl(url); + const imgClass = hasUrl ? "rmc-image" : "rmc-image hidden"; + const placeholderClass = hasUrl ? "menu-image-placeholder hidden" : "menu-image-placeholder"; + const srcAttr = hasUrl ? ` src="${url}"` : ""; + const onerror = hasUrl ? ' onerror="handleMenuImageError(this)"' : ""; + + return `
`; +} + +function findMenuItem(categoryId, position) { + if (!window.menuDataRaw) return null; + for (const cat of window.menuDataRaw) { + for (const item of cat.items) { + if (item.categoryId == categoryId && item.position == position) { + return item; + } + } + } + return null; +} + +function buildVisibleMenuItemKeys() { + const keys = []; + document.querySelectorAll(".rmc-position").forEach((el) => { + if (el.style.display === "none") return; + const category = el.closest(".rm-category"); + if (category && category.style.display === "none") return; + keys.push({ + categoryId: el.getAttribute("data-category-id"), + position: el.getAttribute("data-position"), + }); + }); + return keys; +} + +function updateItemModalNavigation() { + const total = itemModalKeys.length; + const counter = document.getElementById("itemModalCounter"); + if (counter) counter.textContent = total > 1 ? `${itemModalIndex + 1} / ${total}` : ""; +} + +function populateItemModal(item) { + const imgEl = document.getElementById("itemModalImage"); + const placeholderEl = document.getElementById("itemModalImagePlaceholder"); + const titleEl = document.getElementById("itemModalTitle"); + const descEl = document.getElementById("itemModalDesc"); + const priceEl = document.getElementById("itemModalPrice"); + + applyMenuItemImage(imgEl, placeholderEl, item.image); + if (titleEl) titleEl.textContent = item.title; + if (descEl) descEl.textContent = item.description || ""; + if (priceEl) priceEl.textContent = item.price; + updateItemModalNavigation(); +} + +export async function navigateItemModal(delta) { + if (!itemModalKeys.length || !delta || itemModalAnimating) return; + const nextIndex = itemModalIndex + delta; + if (nextIndex < 0 || nextIndex >= itemModalKeys.length) return; + + const key = itemModalKeys[nextIndex]; + const item = findMenuItem(key.categoryId, key.position); + const pane = document.getElementById("itemModalPane"); + if (!item || !pane) return; + + itemModalAnimating = true; + resetItemModalPane(); + + const exitClass = delta > 0 ? "is-exiting-left" : "is-exiting-right"; + const enterClass = delta > 0 ? "is-entering-from-right" : "is-entering-from-left"; + + pane.classList.add(exitClass); + await waitForPaneTransition(pane); + + itemModalIndex = nextIndex; + populateItemModal(item); + + pane.classList.remove(exitClass); + pane.classList.add(enterClass); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + pane.classList.remove(enterClass); + }); + }); + + await waitForPaneTransition(pane); + itemModalAnimating = false; +} + +export function bindItemModalSwipe() { + const content = document.getElementById("itemModalContent"); + const pane = document.getElementById("itemModalPane"); + const modal = document.getElementById("itemModal"); + if (!content || !pane || !modal || content.dataset.swipeBound) return; + content.dataset.swipeBound = "1"; + + content.addEventListener( + "touchstart", + (e) => { + if (itemModalAnimating || itemModalKeys.length <= 1 || e.touches.length !== 1) return; + if (e.target.closest("button")) return; + const touch = e.touches[0]; + itemModalTouchStart = { x: touch.clientX, y: touch.clientY }; + itemModalDragging = false; + }, + { passive: true } + ); + + content.addEventListener( + "touchmove", + (e) => { + if (!itemModalTouchStart || itemModalAnimating || itemModalKeys.length <= 1) return; + const touch = e.touches[0]; + const dx = touch.clientX - itemModalTouchStart.x; + const dy = touch.clientY - itemModalTouchStart.y; + + if (!itemModalDragging) { + if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy)) { + itemModalDragging = true; + pane.classList.add("is-dragging"); + } else if (Math.abs(dy) > 12) { + itemModalTouchStart = null; + return; + } else { + return; + } + } + + let translateX = dx; + if (itemModalIndex <= 0 && dx > 0) translateX = dx * 0.3; + if (itemModalIndex >= itemModalKeys.length - 1 && dx < 0) translateX = dx * 0.3; + + pane.style.transform = `translateX(${translateX}px)`; + pane.style.opacity = String(1 - Math.min(Math.abs(translateX) / 320, 0.18)); + }, + { passive: true } + ); + + content.addEventListener( + "touchend", + (e) => { + if (!itemModalTouchStart) return; + const touch = e.changedTouches[0]; + const dx = touch.clientX - itemModalTouchStart.x; + const dy = touch.clientY - itemModalTouchStart.y; + const wasDragging = itemModalDragging; + + itemModalTouchStart = null; + itemModalDragging = false; + + if (!wasDragging) return; + + pane.classList.remove("is-dragging"); + pane.style.transform = ""; + pane.style.opacity = ""; + + if (Math.abs(dx) >= 50 && Math.abs(dx) > Math.abs(dy)) { + navigateItemModal(dx < 0 ? 1 : -1); + } + }, + { passive: true } + ); + + document.addEventListener("keydown", (e) => { + if (!modal.classList.contains("active")) return; + if (e.key === "ArrowRight") navigateItemModal(1); + if (e.key === "ArrowLeft") navigateItemModal(-1); + if (e.key === "Escape") closeItemModal(); + }); +} + +export async function loadMenu() { + try { + 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; + const container = document.getElementById("menuContainer"); + if (!container) return; + + container.innerHTML = ""; + + menuData.forEach((category) => { + const catId = category.items.length > 0 ? category.items[0].categoryId : ""; + + const catDiv = document.createElement("div"); + catDiv.className = "rm-category"; + catDiv.setAttribute("data-cat-id", catId); + + let html = `
${category.categoryName}
+
`; + + category.items.forEach((item) => { + html += ` +
+ ${renderMenuListImage(item.image)} +
+

${item.title}${item.description}

+
+
${item.price}
+
+ `; + }); + + html += `
`; + catDiv.innerHTML = html; + container.appendChild(catDiv); + }); + } catch (err) { + console.error("Błąd ładowania menu:", err); + const container = document.getElementById("menuContainer"); + if (container) { + container.innerHTML = + '

Nie udało się załadować menu.

'; + } + } +} + +export function openItemModal(categoryId, position) { + itemModalKeys = buildVisibleMenuItemKeys(); + itemModalIndex = itemModalKeys.findIndex( + (key) => key.categoryId == categoryId && key.position == position + ); + + if (itemModalIndex < 0) { + itemModalKeys = [{ categoryId, position }]; + itemModalIndex = 0; + } + + const foundItem = findMenuItem(categoryId, position); + if (!foundItem) return; + + populateItemModal(foundItem); + resetItemModalPane(); + + const modal = document.getElementById("itemModal"); + if (modal) { + modal.classList.add("active"); + document.body.style.overflow = "hidden"; + } +} + +export function closeItemModal() { + const modal = document.getElementById("itemModal"); + if (modal) { + modal.classList.remove("active"); + document.body.style.overflow = ""; + } + itemModalTouchStart = null; + itemModalDragging = false; + itemModalAnimating = false; + resetItemModalPane(); +} + +export function filterMenu() { + const query = document.getElementById("menuSearchInput").value.toLowerCase(); + const now = Date.now(); + if (query.length >= 2 && (!window.lastMenuSearchEventAt || now - window.lastMenuSearchEventAt > 8000)) { + window.lastMenuSearchEventAt = now; + trackEvent("menu_search", { queryLength: query.length }); + } + const categories = document.querySelectorAll(".rm-category"); + + categories.forEach((category) => { + let hasVisibleItems = false; + const items = category.querySelectorAll(".rmc-position"); + + items.forEach((item) => { + const title = item.querySelector(".rmc-title h4").textContent.toLowerCase(); + if (title.includes(query)) { + item.style.display = ""; + hasVisibleItems = true; + } else { + item.style.display = "none"; + } + }); + + if (hasVisibleItems) { + category.style.display = ""; + } else { + category.style.display = "none"; + } + }); +} + +export function showCategory(categoryId) { + document.querySelectorAll(".menu-categories-nav a").forEach((a) => a.classList.remove("active")); + + const clickedLink = document.querySelector(`.menu-categories-nav a[data-category-badge="${categoryId}"]`); + if (clickedLink) { + clickedLink.classList.add("active"); + clickedLink.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + } + + const categories = document.querySelectorAll(".rm-category"); + categories.forEach((cat) => { + if (categoryId === 0) { + cat.style.display = ""; + } else { + const catId = parseInt(cat.getAttribute("data-cat-id"), 10); + if (catId === categoryId) { + cat.style.display = ""; + } else { + cat.style.display = "none"; + } + } + }); +} diff --git a/public/assets/js/modules/orders.js b/public/assets/js/modules/orders.js new file mode 100644 index 0000000..b53dc02 --- /dev/null +++ b/public/assets/js/modules/orders.js @@ -0,0 +1,427 @@ +import { endpoints, loaderMinMs } from "./config.js"; +import { runPendingProtectedAction, updateNavAccessState } from "./access.js"; +import { + refreshGuestPendingActions, + startGuestPendingPoll, +} from "./queue.js"; +import { + getAppAccessLevel, + getHashParam, + getPendingProtectedAction, + getTableParam, + setTableParam, +} from "./state.js"; + +const loadingScreen = document.getElementById("loadingScreen"); +const loaderMsg = document.getElementById("loaderMsg"); +const tableLabel = document.getElementById("tableLabel"); +const prepStatus = document.getElementById("prepStatus"); +const progressBar = document.getElementById("progressBar"); +const statusMeta = document.getElementById("statusMeta"); +const itemsList = document.getElementById("itemsList"); +const emptyState = document.getElementById("emptyState"); +const metaFooter = document.getElementById("metaFooter"); +const statusIcon = document.getElementById("statusIcon"); +const historyKey = "stolik2_global_history"; +const historySection = document.getElementById("historySection"); +const historyList = document.getElementById("historyList"); +const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000; + +const LOADER_MIN_MS = loaderMinMs; +const loadStartTime = Date.now(); + +const msgs = ["Rozgrzewamy piece...", "Szef kuchni sprawdza składniki...", "Łączenie z sercem restauracji...", "Prawie gotowe..."]; +let msgIdx = 0; +const msgInterval = setInterval(() => { + msgIdx = (msgIdx + 1) % msgs.length; + if (loaderMsg) loaderMsg.textContent = msgs[msgIdx]; +}, 4000); + +// Initial State +if (getTableParam()) { + const tableParam = getTableParam(); + tableLabel.textContent = tableParam.toUpperCase().startsWith("STOLIK") ? tableParam : `Stolik ${tableParam}`; +} + +/** Storage key must follow current hash (fallback: table), not a frozen empty tableParam at init. */ +export function getOrderStorageKey() { + const key = (getHashParam() || getTableParam() || "unknown").toLowerCase(); + return `stolik2_state_${key}`; +} + +export function hideLoader() { + const elapsed = Date.now() - loadStartTime; + const remaining = Math.max(0, LOADER_MIN_MS - elapsed); + setTimeout(() => { + loadingScreen.classList.add("hidden"); + clearInterval(msgInterval); + const bottomNav = document.getElementById("bottomNav"); + if (bottomNav) { + bottomNav.style.display = ""; + } + updateNavAccessState(); + if (getPendingProtectedAction() && getAppAccessLevel() === "full") { + runPendingProtectedAction(); + } + }, remaining); +} + +export async function resolveTableLabel() { + const hashParam = getHashParam(); + if (!hashParam) return; + try { + const response = await fetch(`${endpoints.kds}?h=${encodeURIComponent(hashParam)}`); + const result = await response.json(); + if (result.status === "success" && result.tableName && result.tableName !== "") { + tableLabel.textContent = result.tableName.toUpperCase().startsWith("STOLIK") + ? result.tableName + : `Stolik ${result.tableName}`; + setTableParam(result.tableName); + } + } catch { + // best effort + } +} + +export function updateUI(bills) { + // Hide loader after minimum display time + hideLoader(); + + const allArticles = bills.flatMap((b) => (Array.isArray(b?.Articles) ? b.Articles : [])); + const items = mergeWithPersistedItems(allArticles); + renderGlobalHistory(); + + if (items.length === 0) { + showEmptyState(); + return; + } + + renderItems(items); + updateStatus(bills, items); +} + +function showEmptyState() { + const title = document.getElementById("ordersTitle"); + if (title) title.classList.add("hidden"); + + emptyState.classList.remove("hidden"); + itemsList.innerHTML = ""; + prepStatus.textContent = "Brak aktywnych zamówień"; + statusIcon.textContent = "🍃"; + progressBar.style.width = "0%"; + statusMeta.textContent = "Zapraszamy do sprawdzenia naszego menu."; + // Historia może istnieć nawet gdy brak bieżących pozycji + renderGlobalHistory(); +} + +function normalizeArticleName(rawName) { + const name = String(rawName || "Pozycja"); + + // Usuwa gramatury typu: "300G", "250 G", "500/200/150G". + const withoutWeight = name.replace( + /\b\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)*\s*[gG]\b/g, + "" + ); + + return withoutWeight + .replace(/\s{2,}/g, " ") + .replace(/\s+([,.;:!?])/g, "$1") + .trim() || "Pozycja"; +} + +function loadPersistedItems() { + try { + const raw = localStorage.getItem(getOrderStorageKey()); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function savePersistedItems(items) { + try { + localStorage.setItem(getOrderStorageKey(), JSON.stringify(items)); + } catch { + // brak miejsca/tryb prywatny + } +} + +function loadGlobalHistory() { + try { + const raw = localStorage.getItem(historyKey); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveGlobalHistory(entries) { + const now = Date.now(); + const cleaned = entries + .filter((e) => e && e.name && Number.isFinite(e.archivedAt)) + .filter((e) => now - e.archivedAt <= SIX_MONTHS_MS); + + try { + localStorage.setItem(historyKey, JSON.stringify(cleaned)); + } catch { + // ignore storage errors + } +} + +function addItemsToGlobalHistory(items, sourceTable) { + if (!items.length) return; + const now = Date.now(); + const existing = loadGlobalHistory(); + + // zapobiegamy dokładnym duplikatom (ta sama nazwa + qty + stół blisko czasu) + const dedupWindowMs = 2 * 60 * 1000; + const toAdd = items + .filter((item) => { + return !existing.some( + (h) => + h.name === item.name && + Number(h.qty) === Number(item.qty) && + String(h.sourceTable || "") === String(sourceTable || "") && + Math.abs((h.archivedAt || 0) - now) <= dedupWindowMs + ); + }) + .map((item) => ({ + name: item.name, + qty: item.qty, + sourceTable: sourceTable || "?", + archivedAt: now, + })); + + if (!toAdd.length) return; + saveGlobalHistory([...existing, ...toAdd]); +} + +export function renderGlobalHistory() { + const now = Date.now(); + const history = loadGlobalHistory() + .filter((e) => now - (e.archivedAt || 0) <= SIX_MONTHS_MS) + .sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0)); + + saveGlobalHistory(history); + + if (!history.length) { + historySection.classList.add("hidden"); + historyList.innerHTML = ""; + return; + } + + historySection.classList.remove("hidden"); + historyList.innerHTML = ""; + + history.forEach((entry) => { + const dt = new Date(entry.archivedAt || Date.now()); + const div = document.createElement("div"); + div.className = "item-card archived ready"; + div.innerHTML = ` +
+ ${entry.name} + Stolik ${entry.sourceTable || "?"} • ${dt.toLocaleDateString("pl-PL")} ${dt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+
x${entry.qty}
+ `; + historyList.appendChild(div); + }); +} + +export function clearGlobalHistory(e) { + if (e) e.preventDefault(); + if (confirm("Czy na pewno chcesz usunąć historię swoich poprzednich zamówień?")) { + localStorage.removeItem(historyKey); + renderGlobalHistory(); + } +} + +function mergeWithPersistedItems(articles) { + const current = new Map(); + + articles.forEach((a) => { + const name = normalizeArticleName(a.Name); + const todo = parseFloat(String(a.QuantityToDo || a.QuantitySet || "0").replace(",", ".")); + const done = parseFloat(String(a.QuantityDone || "0").replace(",", ".")); + + if (!current.has(name)) { + current.set(name, { name, qty: 0, done: 0, present: true, completedByDisappear: false }); + } + const curr = current.get(name); + curr.qty += Number.isFinite(todo) ? todo : 0; + curr.done += Number.isFinite(done) ? done : 0; + }); + + const persisted = loadPersistedItems(); + const persistedMap = new Map(persisted.map((i) => [i.name, i])); + const merged = []; + + // Aktualnie obecne pozycje z WS + current.forEach((item) => { + merged.push({ + ...item, + present: true, + completedByDisappear: false, + updatedAt: Date.now(), + }); + }); + + // Pozycje, które były wcześniej, ale zniknęły z API -> przesyłamy od razu do historii globalnej + persistedMap.forEach((oldItem, name) => { + if (current.has(name)) return; + const qty = Number.isFinite(oldItem?.qty) ? oldItem.qty : 0; + + // Zniknęło z bieżącego rachunku (np. rachunek został zamknięty), od razu leci do osobnego bloku historii! + addItemsToGlobalHistory([{ name, qty }], getTableParam()); + }); + + // Aktywne na górze, gotowe (zniknięte) na dole + merged.sort((a, b) => { + if (a.present !== b.present) return a.present ? -1 : 1; + return a.name.localeCompare(b.name, "pl"); + }); + + savePersistedItems(merged); + return merged; +} + +function renderItems(items) { + const title = document.getElementById("ordersTitle"); + if (title) title.classList.remove("hidden"); + + emptyState.classList.add("hidden"); + itemsList.innerHTML = ""; + + items.forEach((item) => { + const isReady = item.done >= item.qty && item.qty > 0; + const div = document.createElement("div"); + div.className = `item-card ${isReady ? "ready" : ""} ${item.present ? "" : "archived"}`; + + let meta = "🔥 W przygotowaniu"; + if (isReady && item.completedByDisappear) { + meta = "✅ Gotowe (zrealizowane)"; + } else if (isReady) { + meta = "✅ Gotowe"; + } + + div.innerHTML = ` +
+ ${item.name} + ${meta} +
+
x${item.qty}
+ `; + itemsList.appendChild(div); + }); + + renderGlobalHistory(); +} + +function updateStatus(bills, items) { + let total = 0; + let done = 0; + items.forEach((i) => { + total += Number.isFinite(i.qty) ? i.qty : 0; + done += Number.isFinite(i.done) ? i.done : 0; + }); + + const pct = total > 0 ? (done / total) * 100 : 0; + progressBar.style.width = `${pct}%`; + + if (pct >= 100) { + prepStatus.textContent = "Gotowe do podania!"; + statusIcon.innerHTML = "😋"; + statusMeta.textContent = "Wszystkie Twoje dania opuściły już kuchnię."; + } else if (pct > 0) { + prepStatus.textContent = "Częściowo gotowe"; + statusIcon.innerHTML = "🍳"; + statusMeta.textContent = "Pierwsze pyszności już na Ciebie czekają!"; + } else { + prepStatus.textContent = "W przygotowaniu"; + if (!window.selectedAnimationHtml) { + window.selectedAnimationHtml = + window.kitchenAnimations[Math.floor(Math.random() * window.kitchenAnimations.length)]; + } + statusIcon.innerHTML = window.selectedAnimationHtml; + statusMeta.textContent = "Twoje zamówienie jest właśnie tworzone przez naszych kucharzy."; + } + + // Footer meta + const newest = [...bills].sort((a, b) => new Date(b?.Date || 0) - new Date(a?.Date || 0))[0]; + const time = newest?.Date + ? new Date(newest.Date).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : "--:--"; + metaFooter.textContent = `Zamówienie złożone o godzinie ${time} • Stolik ${getTableParam()}`; +} + +export async function fetchOrders() { + try { + const hashParam = getHashParam(); + if (!hashParam) { + updateUI([]); + return; + } + + const response = await fetch(`${endpoints.kds}?h=${encodeURIComponent(hashParam)}`); + const result = await response.json(); + + if (result.status === "success") { + if (result.tableName && result.tableName !== "") { + tableLabel.textContent = result.tableName.toUpperCase().startsWith("STOLIK") + ? result.tableName + : `Stolik ${result.tableName}`; + setTableParam(result.tableName); // Aktualizacja do właściwej nazwy na poczet innych zapytań + refreshGuestPendingActions(); + startGuestPendingPoll(); + } + + // API teraz samo filtruje i zwraca tylko to co nas interesuje (za pomocą mocnego wyrażenia regularnego) + const matches = result.data; + + // Grupowanie składników w główne dania + const groups = {}; + matches.forEach((item) => { + const groupId = item.GrupaZestawuID || item.PozycjaID; + if (!groups[groupId]) { + groups[groupId] = { + Name: item.NazwaZestawu || item.NazwaTowaru, + QuantitySet: item.GrupaZestawuID ? 1 : parseFloat(item.Ilosc), + Done: 0, + }; + } + // StatusRealizacji >= 2 oznacza, że kucharz wcisnął "Gotowe" na swoim ekranie + if (parseInt(item.StatusRealizacji, 10) >= 2) { + groups[groupId].Done = groups[groupId].QuantitySet; + } + }); + + const transformedArticles = Object.values(groups).map((g) => ({ + Name: g.Name, + QuantitySet: g.QuantitySet, + QuantityDone: g.Done, + })); + + // Najnowszy czas dodania (do pokazania w stopce) + const latestDate = + matches.length > 0 + ? matches.sort((a, b) => new Date(b.DataDodania) - new Date(a.DataDodania))[0].DataDodania + : null; + + // Przekazanie do dotychczasowej logiki aktualizującej UI (w odpowiednim formacie) + updateUI([ + { + Articles: transformedArticles, + Date: latestDate, + }, + ]); + } else { + loaderMsg.textContent = "Błąd API: " + result.message; + } + } catch (err) { + loaderMsg.textContent = "Problem z połączeniem. Próbujemy ponownie..."; + } +} diff --git a/public/assets/js/modules/queue.js b/public/assets/js/modules/queue.js new file mode 100644 index 0000000..7d77fa7 --- /dev/null +++ b/public/assets/js/modules/queue.js @@ -0,0 +1,208 @@ +import { endpoints } from "./config.js"; +import { showToast } from "./toast.js"; +import { + getBillState, + getCachedOpenBills, + getGuestPendingActions, + getHashParam, + getTableParam, + setCachedOpenBills, + setGuestPendingAction, +} from "./state.js"; + +let guestPendingPollTimer = null; +const GUEST_PENDING_POLL_MS = 15000; + +export function cacheOpenBills(bills) { + setCachedOpenBills(bills); +} + +function getQueueOperatorFields() { + const bills = getCachedOpenBills(); + if (!bills.length) { + return { otwierajacyImie: "", otwierajacyNazwisko: "" }; + } + + let bill = bills[0]; + const billState = getBillState(); + if (billState.selectedBillId) { + const selected = bills.find((b) => b.id === billState.selectedBillId); + if (selected) bill = selected; + } + + const o = bill.otwierajacy || {}; + return { + otwierajacyImie: String(o.imie || "").trim(), + otwierajacyNazwisko: String(o.nazwisko || "").trim(), + }; +} + +export async function prefetchOpenBills() { + const hashParam = getHashParam(); + if (!hashParam) return; + try { + const res = await fetch(`${endpoints.bills}?h=${encodeURIComponent(hashParam)}`); + const result = await res.json(); + if (result.status === "success") { + cacheOpenBills(result.data); + } + } catch { + // best effort + } +} + +/** + * Komunikat do kolejki KDS — zwykły tekst, wiersze oddzielone \n (w JSON jako entery). + * @param {string} title + * @param {{ label?: string, value?: string }[]} lines + */ +export function formatGuestQueueMessage(title, lines = []) { + const rows = (Array.isArray(lines) ? lines : []) + .map((line) => { + const label = String(line?.label ?? "").trim(); + const value = String(line?.value ?? "").trim(); + if (!label && !value) return ""; + if (label && value) return `${label} ${value}`; + return label || value; + }) + .filter(Boolean); + + if (!rows.length) { + return title; + } + + return `${title}\n${rows.join("\n")}`; +} + +export function buildWaiterCallQueueMessage() { + return "Przywołanie kelnera"; +} + +export function buildBillRequestQueueMessage(docType) { + const billState = getBillState(); + const lines = [ + { label: "Forma płatności:", value: billState.payment || "nieznana" }, + { label: "Dokument:", value: docType === "faktura" ? "faktura" : "paragon" }, + ]; + + if (docType === "faktura") { + lines.push({ label: "NIP:", value: billState.nip || "—" }); + lines.push({ label: "Firma:", value: billState.company?.name || "—" }); + const addressParts = [ + billState.company?.street, + [billState.company?.zip, billState.company?.city].filter(Boolean).join(" "), + ].filter(Boolean); + if (addressParts.length) { + lines.push({ label: "Adres:", value: addressParts.join(", ") }); + } + } + + return formatGuestQueueMessage("Prośba o rachunek", lines); +} + +export function guestActionBlockedMessage(messageType) { + if (messageType === "waiter_call") { + return "Kelner został już wezwany. Poczekaj, aż obsługa potwierdzi zgłoszenie na panelu."; + } + return "Prośba o rachunek została już wysłana. Poczekaj, aż obsługa ją obsłuży."; +} + +export function updateGuestActionNavState() { + const guestPendingActions = getGuestPendingActions(); + const waiterNav = document.querySelector(".bottom-nav .action-call"); + const billNav = document.querySelector(".bottom-nav .action-bill"); + if (waiterNav) { + waiterNav.classList.toggle("nav-action-pending", guestPendingActions.waiter_call); + } + if (billNav) { + billNav.classList.toggle("nav-action-pending", guestPendingActions.bill_request); + } +} + +export async function refreshGuestPendingActions() { + const hashParam = getHashParam(); + const tableParam = getTableParam(); + const guestPendingActions = getGuestPendingActions(); + + if (!hashParam && !tableParam) { + return guestPendingActions; + } + + const params = new URLSearchParams(); + if (hashParam) params.set("h", hashParam); + if (tableParam) params.set("tableId", tableParam); + + try { + const res = await fetch(`${endpoints.guestActionQueue}?${params.toString()}`); + const result = await res.json(); + if (result.status === "success" && result.pending) { + setGuestPendingAction("waiter_call", !!result.pending.waiter_call); + setGuestPendingAction("bill_request", !!result.pending.bill_request); + updateGuestActionNavState(); + } + } catch { + // best effort + } + + return getGuestPendingActions(); +} + +export function startGuestPendingPoll() { + if (guestPendingPollTimer) return; + guestPendingPollTimer = setInterval(() => { + if (!getHashParam() && !getTableParam()) return; + refreshGuestPendingActions(); + }, GUEST_PENDING_POLL_MS); +} + +export async function ensureGuestActionAllowed(messageType) { + await refreshGuestPendingActions(); + if (!getGuestPendingActions()[messageType]) { + return true; + } + showToast(guestActionBlockedMessage(messageType)); + return false; +} + +export async function queueGuestAction(messageType, messageText, extra = {}) { + const operator = getQueueOperatorFields(); + const body = { + tableId: getTableParam() || null, + qrHash: getHashParam() || null, + messageType, + messageText, + otwierajacyImie: operator.otwierajacyImie, + otwierajacyNazwisko: operator.otwierajacyNazwisko, + extra, + }; + + try { + const res = await fetch(endpoints.guestActionQueue, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + keepalive: true, + }); + const result = await res.json().catch(() => ({})); + + if (res.status === 409 || result.code === "pending_on_kds") { + setGuestPendingAction(messageType, true); + updateGuestActionNavState(); + return { ok: false, reason: "pending" }; + } + + if (res.status === 429 || result.code === "rate_limited") { + return { ok: false, reason: "error" }; + } + + if (!res.ok || result.status !== "success") { + return { ok: false, reason: "error" }; + } + + setGuestPendingAction(messageType, true); + updateGuestActionNavState(); + return { ok: true }; + } catch { + return { ok: false, reason: "error" }; + } +} diff --git a/public/assets/js/modules/state.js b/public/assets/js/modules/state.js new file mode 100644 index 0000000..e47690e --- /dev/null +++ b/public/assets/js/modules/state.js @@ -0,0 +1,93 @@ +window.kitchenAnimations = [ + `
🔥
🍳
`, + `
🐓
👨‍🍳
`, + `
🐓
🍲
`, + `
👨‍🍳
🍕
`, + `
🐷
💦
🍴
` +]; +window.selectedAnimationHtml = null; + +let hashParam = ""; +let tableParam = ""; +let appAccessLevel = "none"; // "none" | "menu" | "full" +let pendingProtectedAction = null; // "status" | "waiter" | "bill" +let billState = { payment: "", doc: "", nip: "", company: null }; +let cachedOpenBills = []; +let guestPendingActions = { + waiter_call: false, + bill_request: false, +}; +let isStaffPreview = false; +let analyticsSessionId = ""; + +export function getHashParam() { + return hashParam; +} + +export function setHashParam(value) { + hashParam = (value || "").trim(); +} + +export function getTableParam() { + return tableParam; +} + +export function setTableParam(value) { + tableParam = value || ""; +} + +export function getAppAccessLevel() { + return appAccessLevel; +} + +export function setAppAccessLevel(value) { + appAccessLevel = value; +} + +export function getPendingProtectedAction() { + return pendingProtectedAction; +} + +export function setPendingProtectedAction(value) { + pendingProtectedAction = value; +} + +export function getBillState() { + return billState; +} + +export function setBillState(value) { + billState = value; +} + +export function getCachedOpenBills() { + return cachedOpenBills; +} + +export function setCachedOpenBills(value) { + cachedOpenBills = Array.isArray(value) ? value : []; +} + +export function getGuestPendingActions() { + return guestPendingActions; +} + +export function setGuestPendingAction(messageType, pending) { + guestPendingActions[messageType] = !!pending; +} + +export function getIsStaffPreview() { + return isStaffPreview; +} + +export function setIsStaffPreview(value) { + isStaffPreview = !!value; +} + +export function getAnalyticsSessionId() { + return analyticsSessionId; +} + +export function setAnalyticsSessionId(value) { + analyticsSessionId = value || ""; +} diff --git a/public/assets/js/modules/toast.js b/public/assets/js/modules/toast.js new file mode 100644 index 0000000..941c971 --- /dev/null +++ b/public/assets/js/modules/toast.js @@ -0,0 +1,10 @@ +export function showToast(msg) { + const t = document.getElementById("toastMsg"); + document.getElementById("toastText").textContent = msg; + + t.classList.remove("active"); + void t.offsetWidth; // trigger reflow + t.classList.add("active"); + + setTimeout(() => t.classList.remove("active"), 3500); +} diff --git a/public/includes/asset_version.php b/public/includes/asset_version.php index 65fe1f3..e7191a4 100644 --- a/public/includes/asset_version.php +++ b/public/includes/asset_version.php @@ -17,6 +17,28 @@ function publicAssetVersion(string $publicDir, string $relativePath): string return $mtime !== false ? (string) $mtime : '0'; } +/** + * Najnowszy mtime wśród pliku głównego i wszystkich .js w katalogu modules/. + */ +function publicJsBundleVersion(string $publicDir, string $entryRelativePath, string $modulesRelativeDir = 'assets/js/modules'): string +{ + $versions = [(int) publicAssetVersion($publicDir, $entryRelativePath)]; + $modulesDir = $publicDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($modulesRelativeDir, '/')); + + if (is_dir($modulesDir)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($modulesDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'js') { + $versions[] = (int) $file->getMTime(); + } + } + } + + return (string) max($versions); +} + function assetVersionAttr(string $version): string { return htmlspecialchars($version, ENT_QUOTES, 'UTF-8');