631 lines
17 KiB
JavaScript
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();
|
|
}
|
|
}
|