window.kitchenAnimations = [ `
🔥
🍳
`, `
🐓
👨‍🍳
`, `
🐓
🍲
`, `
👨‍🍳
🍕
`, `
🐷
💦
🍴
` ]; window.selectedAnimationHtml = null; const MENU_ASSET_VERSION = window.MENU_ASSET_VERSION || window.APP_ASSET_VERSION || "1"; const params = new URLSearchParams(location.search); let hashParam = (params.get("h") || "").trim(); const isStaffPreview = params.get("preview") === "staff"; // Jeśli brak hasha w URL – zapytaj użytkownika (np. do testów) if (!hashParam) { const input = prompt("Podaj bezpieczny hash stolika (wymagane):"); const trimmed = (input || "").trim(); if (trimmed) { const newUrl = new URL(location.href); newUrl.searchParams.set("h", trimmed); location.replace(newUrl.toString()); } } 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"; 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; } 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) { 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(); }