Files
karczma-aplikacja-stoliki/public/assets/js/modules/geo.js
T

631 lines
17 KiB
JavaScript

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();
}
}