import { endpoints, geoBypassHosts } from "./config.js"; import { onLangChange, t } from "./i18n.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"; /** @typedef {'consent'|'gate'|'blocked'|'checking'|'outside'|'https'|'unsupported'|'failed'} GeoUiMode */ const geoUi = { /** @type {GeoUiMode} */ mode: "consent", pendingAction: null, outsideDist: 0, outsideAccuracy: 0, actionBusy: false, /** @type {'locate'|'check'|'retry'|'checking'} */ actionLabel: "locate", /** @type {'menu_only'|'back_to_menu'} */ secondaryMode: "menu_only", }; function GEO_GATE_LABELS() { return { status: t("geo.feature.status"), waiter: t("geo.feature.waiter"), bill: t("geo.feature.bill"), }; } 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 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 applyGeoActionLabel() { const keys = { locate: "geo.btn.locate", check: "geo.btn.check", retry: "geo.btn.retry", checking: "geo.btn.checking", }; setGeoActionLabel(t(keys[geoUi.actionLabel] || keys.locate)); } function setGeoActionBusy(busy) { const btn = document.getElementById("geoActionBtn"); if (!btn) return; btn.disabled = false; btn.setAttribute("aria-busy", busy ? "true" : "false"); geoUi.actionBusy = !!busy; if (busy) { geoUi.actionLabel = "checking"; } applyGeoActionLabel(); } function applyGeoSecondaryButton() { const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn"); if (!menuOnlyBtn) return; const mainEl = menuOnlyBtn.querySelector(".geo-btn-main"); const subEl = menuOnlyBtn.querySelector(".geo-btn-sub"); menuOnlyBtn.style.display = ""; if (geoUi.secondaryMode === "back_to_menu") { if (mainEl) mainEl.textContent = t("geo.btn.back_menu"); if (subEl) { subEl.textContent = ""; subEl.style.display = "none"; } return; } if (mainEl) mainEl.textContent = t("geo.btn.menu_main"); if (subEl) { subEl.textContent = t("geo.btn.menu_sub"); subEl.style.display = ""; } } function refreshGeoCopy() { const secondary = getAppAccessLevel() === "menu" && geoUi.secondaryMode === "back_to_menu" ? "back_to_menu" : geoUi.secondaryMode; geoUi.secondaryMode = secondary; switch (geoUi.mode) { case "gate": { const feature = GEO_GATE_LABELS()[geoUi.pendingAction] || t("geo.feature.other"); setGeoLead(t("geo.lead_action", { feature })); break; } case "blocked": setGeoLead(t("geo.lead")); setGeoStatus(t("geo.status.blocked"), { error: true }); showGeoInstructions(t("geo.hint.permission")); break; case "checking": setGeoLead(t("geo.lead")); setGeoStatus(t("geo.status.checking"), { info: true }); hideGeoInstructions(); break; case "outside": setGeoLead(t("geo.lead")); setGeoStatus( t("geo.status.outside", { dist: geoUi.outsideDist, accuracy: geoUi.outsideAccuracy, }), { error: true } ); showGeoInstructions(t("geo.hint.outside")); break; case "https": setGeoLead(t("geo.lead")); setGeoStatus(t("geo.status.https"), { error: true }); showGeoInstructions(t("geo.hint.https")); break; case "unsupported": setGeoLead(t("geo.lead")); setGeoStatus(t("geo.status.unsupported"), { error: true }); hideGeoInstructions(); break; case "failed": setGeoLead(t("geo.lead")); setGeoStatus(t("geo.status.failed"), { error: true }); showGeoInstructions(t("geo.hint.retry")); break; case "consent": default: setGeoLead(t("geo.lead")); break; } applyGeoActionLabel(); applyGeoSecondaryButton(); } onLangChange(() => { refreshGeoCopy(); }); /* --- Disabled user profile (name modal) — kept for possible re-enable --- */ const userProfileKey = "karczma_user_profile"; const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; export function initUserProfile() { return; } export function saveUserName() { const input = document.getElementById("userNameInput"); if (!input) return; const name = input.value.trim(); if (!name) return; const now = Date.now(); localStorage.setItem( userProfileKey, JSON.stringify({ name, firstVisit: now, lastVisit: now, declined: false }) ); } export function declineUserName() { localStorage.setItem( userProfileKey, JSON.stringify({ name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true }) ); } export function handleGeoMenuClick() { if (geoUi.secondaryMode === "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(); }); } export function showGeoGateForAction(action) { const geoScreen = document.getElementById("geoScreen"); const loadingScreen = document.getElementById("loadingScreen"); if (loadingScreen) loadingScreen.classList.add("hidden"); if (geoScreen) geoScreen.classList.remove("hidden"); geoUi.mode = "gate"; geoUi.pendingAction = action; geoUi.actionBusy = false; geoUi.actionLabel = "check"; geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; setGeoStatus(""); hideGeoInstructions(); setGeoActionBusy(false); refreshGeoCopy(); } 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 isGeoPermissionDenied(error) { return Number(error?.code) === 1; } function showGeoConsentScreen() { const geoScreen = document.getElementById("geoScreen"); const loadingScreen = document.getElementById("loadingScreen"); loadingScreen.classList.add("hidden"); geoScreen.classList.remove("hidden"); geoUi.mode = "consent"; geoUi.pendingAction = null; geoUi.actionLabel = "locate"; geoUi.secondaryMode = "menu_only"; setGeoStatus(""); hideGeoInstructions(); setGeoActionBusy(false); refreshGeoCopy(); } function showGeoPermissionBlockedState() { geoUi.mode = "blocked"; geoUi.actionLabel = "retry"; geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; setGeoActionBusy(false); refreshGeoCopy(); } 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"); geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; if (!window.isSecureContext) { geoUi.mode = "https"; geoUi.actionLabel = "retry"; setGeoActionBusy(false); refreshGeoCopy(); return; } if (!navigator.geolocation) { geoUi.mode = "unsupported"; geoUi.actionLabel = "retry"; setGeoActionBusy(false); refreshGeoCopy(); return; } geoUi.mode = "checking"; setGeoActionBusy(true); refreshGeoCopy(); 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), }); geoUi.mode = "outside"; geoUi.outsideDist = Math.round(dist); geoUi.outsideAccuracy = Math.round(accuracy); geoUi.actionLabel = "retry"; geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; setGeoActionBusy(false); refreshGeoCopy(); } catch (error) { trackEvent("geo_check_failed", { reason: "browser_error", code: error.code || null, message: String(error.message || ""), }); const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || "")); if (deniedBecauseInsecure) { geoUi.mode = "https"; geoUi.actionLabel = "retry"; geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; setGeoActionBusy(false); refreshGeoCopy(); } else if (isGeoPermissionDenied(error)) { showGeoPermissionBlockedState(); } else { geoUi.mode = "failed"; geoUi.actionLabel = "retry"; geoUi.secondaryMode = getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only"; setGeoActionBusy(false); refreshGeoCopy(); } } } export function startGeoBootstrap() { bindGeoScreenButtons(); if (shouldBypassGeolocationHost()) { bypassGeolocation("trusted_host", { host: window.location.hostname }); } else { bootstrapGeolocation(); } }