619 lines
18 KiB
JavaScript
619 lines
18 KiB
JavaScript
import { endpoints, geoBypassHosts } from "./config.js";
|
|
import { 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";
|
|
|
|
// USER PROFILE LOGIC
|
|
const userProfileKey = "karczma_user_profile";
|
|
const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months
|
|
|
|
export function initUserProfile() {
|
|
return; // Funkcja tymczasowo wyłączona
|
|
try {
|
|
const raw = localStorage.getItem(userProfileKey);
|
|
let profile = null;
|
|
if (raw) {
|
|
profile = JSON.parse(raw);
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Check if profile exists and is valid
|
|
if (profile) {
|
|
if (profile.declined) {
|
|
// User declined in the past, don't ask again.
|
|
return;
|
|
}
|
|
|
|
// If expired, maybe we want to ask again or we could just keep it if they didn't decline.
|
|
// The user said: "trzymamy przez wiele miesięcy (odnawiamy datę przy każdej wizycie)"
|
|
if (now - profile.lastVisit > USER_PROFILE_EXPIRE_MS) {
|
|
// Profile expired. Ask again.
|
|
showNameDialog();
|
|
} else {
|
|
// Profile valid, renew date and show greeting
|
|
profile.lastVisit = now;
|
|
localStorage.setItem(userProfileKey, JSON.stringify(profile));
|
|
showGreeting(profile.name, profile.firstVisit || profile.lastVisit);
|
|
}
|
|
} else {
|
|
// No profile, ask for name
|
|
showNameDialog();
|
|
}
|
|
} catch (err) {
|
|
// If error parsing, ask again
|
|
showNameDialog();
|
|
}
|
|
}
|
|
|
|
function showNameDialog() {
|
|
const modal = document.getElementById("nameModal");
|
|
if (modal) {
|
|
modal.classList.add("active");
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
}
|
|
|
|
function hideNameDialog() {
|
|
const modal = document.getElementById("nameModal");
|
|
if (modal) {
|
|
modal.classList.remove("active");
|
|
document.body.style.overflow = "";
|
|
}
|
|
}
|
|
|
|
export function saveUserName() {
|
|
const input = document.getElementById("userNameInput").value.trim();
|
|
if (input) {
|
|
const now = Date.now();
|
|
const profile = { name: input, firstVisit: now, lastVisit: now, declined: false };
|
|
localStorage.setItem(userProfileKey, JSON.stringify(profile));
|
|
hideNameDialog();
|
|
showGreeting(profile.name, profile.firstVisit);
|
|
}
|
|
}
|
|
|
|
export function declineUserName() {
|
|
const profile = { name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true };
|
|
localStorage.setItem(userProfileKey, JSON.stringify(profile));
|
|
hideNameDialog();
|
|
}
|
|
|
|
function showGreeting(name, firstVisitTime) {
|
|
const banner = document.getElementById("greetingBanner");
|
|
if (banner && name) {
|
|
const isToday = new Date(firstVisitTime).toDateString() === new Date().toDateString();
|
|
|
|
if (isToday) {
|
|
banner.innerHTML = `Cześć ${name}, życzymy pysznego posiłku!`;
|
|
} else {
|
|
banner.innerHTML = `Witaj ${name}, super że do nas wracasz!`;
|
|
}
|
|
banner.style.display = "block";
|
|
}
|
|
}
|
|
|
|
function GEO_GATE_LABELS() {
|
|
return {
|
|
status: t("geo.feature.status"),
|
|
waiter: t("geo.feature.waiter"),
|
|
bill: t("geo.feature.bill"),
|
|
};
|
|
}
|
|
|
|
const GEO_DEFAULT_LEAD = () => t("geo.lead");
|
|
|
|
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(t("geo.btn.checking"));
|
|
}
|
|
}
|
|
|
|
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() {
|
|
return t("geo.hint.permission");
|
|
}
|
|
|
|
let geoMenuButtonMode = "menu_only";
|
|
|
|
export function handleGeoMenuClick() {
|
|
if (geoMenuButtonMode === "back_to_menu") {
|
|
document.getElementById("geoScreen")?.classList.add("hidden");
|
|
return;
|
|
}
|
|
enterMenuOnlyMode();
|
|
}
|
|
|
|
export function retryGeolocation() {
|
|
if (shouldBypassGeolocationHost()) {
|
|
bypassGeolocation("trusted_host", { host: window.location.hostname });
|
|
return;
|
|
}
|
|
|
|
initGeolocationAfterBypassChecks({ userInitiated: true }).catch((err) => {
|
|
console.error("[GEO] retry failed", err);
|
|
showGeoPermissionBlockedState();
|
|
});
|
|
}
|
|
|
|
export function bindGeoScreenButtons() {
|
|
document.getElementById("geoMenuOnlyBtn")?.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
handleGeoMenuClick();
|
|
});
|
|
|
|
document.getElementById("geoActionBtn")?.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
retryGeolocation();
|
|
});
|
|
}
|
|
|
|
function configureGeoSecondaryButton(mode) {
|
|
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
|
|
if (!menuOnlyBtn) return;
|
|
|
|
geoMenuButtonMode = mode;
|
|
const mainEl = menuOnlyBtn.querySelector(".geo-btn-main");
|
|
const subEl = menuOnlyBtn.querySelector(".geo-btn-sub");
|
|
|
|
if (mode === "back_to_menu") {
|
|
menuOnlyBtn.style.display = "";
|
|
if (mainEl) mainEl.textContent = t("geo.btn.back_menu");
|
|
if (subEl) {
|
|
subEl.textContent = "";
|
|
subEl.style.display = "none";
|
|
}
|
|
return;
|
|
}
|
|
|
|
menuOnlyBtn.style.display = "";
|
|
if (mainEl) mainEl.textContent = t("geo.btn.menu_main");
|
|
if (subEl) {
|
|
subEl.textContent = t("geo.btn.menu_sub");
|
|
subEl.style.display = "";
|
|
}
|
|
}
|
|
|
|
export function showGeoGateForAction(action) {
|
|
const geoScreen = document.getElementById("geoScreen");
|
|
const loadingScreen = document.getElementById("loadingScreen");
|
|
const geoActionBtn = document.getElementById("geoActionBtn");
|
|
|
|
if (loadingScreen) loadingScreen.classList.add("hidden");
|
|
if (geoScreen) geoScreen.classList.remove("hidden");
|
|
|
|
const feature = GEO_GATE_LABELS()[action] || t("geo.feature.other");
|
|
setGeoLead(t("geo.lead_action", { feature }));
|
|
setGeoStatus("");
|
|
hideGeoInstructions();
|
|
if (geoActionBtn) {
|
|
setGeoActionBusy(false);
|
|
setGeoActionLabel(t("geo.btn.check"));
|
|
}
|
|
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
}
|
|
|
|
setGeoGateHandler(showGeoGateForAction);
|
|
|
|
export function unlockFullApp() {
|
|
setAppAccessLevel("full");
|
|
updateNavAccessState();
|
|
document.getElementById("geoScreen")?.classList.add("hidden");
|
|
|
|
const loaderVisible = !document.getElementById("loadingScreen")?.classList.contains("hidden");
|
|
const alreadyStarted = !!window.ordersInterval;
|
|
|
|
if (alreadyStarted && !loaderVisible) {
|
|
trackEvent("session_start", { flow: "unlock_from_menu" });
|
|
initUserProfile();
|
|
fetchOrders();
|
|
prefetchOpenBills();
|
|
refreshGuestPendingActions();
|
|
startGuestPendingPoll();
|
|
runPendingProtectedAction();
|
|
return;
|
|
}
|
|
|
|
startApp();
|
|
}
|
|
|
|
export function enterMenuOnlyMode() {
|
|
trackEvent("menu_only_entered");
|
|
setAppAccessLevel("menu");
|
|
setPendingProtectedAction(null);
|
|
|
|
document.getElementById("geoScreen")?.classList.add("hidden");
|
|
document.getElementById("loadingScreen")?.classList.add("hidden");
|
|
showBottomNav();
|
|
resolveTableLabel();
|
|
switchTabInternal("menu");
|
|
}
|
|
|
|
// --- GEOLOCATION LOGIC ---
|
|
// Dwa punkty odniesienia: OSM (adres budynku) i pin Google Maps (z nim porównują goście w Maps).
|
|
const RESTAURANT_LOCATIONS = [
|
|
{ lat: 50.5622609, lng: 22.0606303, source: "osm" },
|
|
{ lat: 50.567953, lng: 22.061045, source: "google_maps" },
|
|
];
|
|
const MAX_DISTANCE_METERS = 300;
|
|
const MAX_ACCURACY_BONUS = 150;
|
|
|
|
function haversineDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371e3;
|
|
const p1 = (lat1 * Math.PI) / 180;
|
|
const p2 = (lat2 * Math.PI) / 180;
|
|
const dp = ((lat2 - lat1) * Math.PI) / 180;
|
|
const dl = ((lon2 - lon1) * Math.PI) / 180;
|
|
|
|
const a =
|
|
Math.sin(dp / 2) * Math.sin(dp / 2) +
|
|
Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) * Math.sin(dl / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
function distanceToRestaurant(lat, lng) {
|
|
return Math.min(
|
|
...RESTAURANT_LOCATIONS.map(({ lat: rLat, lng: rLng }) => haversineDistance(rLat, rLng, lat, lng))
|
|
);
|
|
}
|
|
|
|
function isInsideRestaurantGeofence(distanceMeters, accuracyMeters) {
|
|
const accuracyBonus = Math.min(Math.max(Number(accuracyMeters) || 0, 0), MAX_ACCURACY_BONUS);
|
|
return distanceMeters <= MAX_DISTANCE_METERS + accuracyBonus;
|
|
}
|
|
|
|
function requestRestaurantGeolocation() {
|
|
const geoOptions = {
|
|
enableHighAccuracy: true,
|
|
timeout: 15000,
|
|
maximumAge: 0,
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let bestSample = null;
|
|
let watchId = null;
|
|
let settled = false;
|
|
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (watchId != null) navigator.geolocation.clearWatch(watchId);
|
|
clearTimeout(timeoutId);
|
|
resolve(result);
|
|
};
|
|
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (watchId != null) navigator.geolocation.clearWatch(watchId);
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
};
|
|
|
|
const handlePosition = (position) => {
|
|
const dist = distanceToRestaurant(position.coords.latitude, position.coords.longitude);
|
|
const accuracy = position.coords.accuracy;
|
|
const sample = { position, dist, accuracy };
|
|
|
|
if (!bestSample || dist < bestSample.dist) {
|
|
bestSample = sample;
|
|
}
|
|
|
|
if (isInsideRestaurantGeofence(dist, accuracy)) {
|
|
finish({ passed: true, ...sample });
|
|
}
|
|
};
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
if (bestSample) {
|
|
finish({
|
|
passed: isInsideRestaurantGeofence(bestSample.dist, bestSample.accuracy),
|
|
...bestSample,
|
|
});
|
|
return;
|
|
}
|
|
fail({ code: 3, message: "Geolocation timeout" });
|
|
}, 12000);
|
|
|
|
watchId = navigator.geolocation.watchPosition(handlePosition, fail, geoOptions);
|
|
});
|
|
}
|
|
|
|
export function startApp() {
|
|
setAppAccessLevel("full");
|
|
updateNavAccessState();
|
|
document.getElementById("geoScreen").classList.add("hidden");
|
|
document.getElementById("loadingScreen").classList.remove("hidden");
|
|
trackEvent("session_start", { flow: "start_app" });
|
|
|
|
initUserProfile();
|
|
fetchOrders();
|
|
prefetchOpenBills();
|
|
refreshGuestPendingActions();
|
|
startGuestPendingPoll();
|
|
if (!window.ordersInterval) {
|
|
window.ordersInterval = setInterval(fetchOrders, 10000);
|
|
}
|
|
|
|
// Fallback: If no data after 25s, show empty state anyway
|
|
setTimeout(() => {
|
|
if (!document.getElementById("loadingScreen").classList.contains("hidden")) {
|
|
updateUI([]);
|
|
}
|
|
}, 25000);
|
|
}
|
|
|
|
function showGeoConsentScreen() {
|
|
const geoScreen = document.getElementById("geoScreen");
|
|
const loadingScreen = document.getElementById("loadingScreen");
|
|
const geoActionBtn = document.getElementById("geoActionBtn");
|
|
|
|
loadingScreen.classList.add("hidden");
|
|
geoScreen.classList.remove("hidden");
|
|
|
|
setGeoLead(GEO_DEFAULT_LEAD());
|
|
setGeoStatus("");
|
|
hideGeoInstructions();
|
|
|
|
if (geoActionBtn) {
|
|
setGeoActionBusy(false);
|
|
setGeoActionLabel(t("geo.btn.locate"));
|
|
}
|
|
configureGeoSecondaryButton("menu_only");
|
|
}
|
|
|
|
function showGeoPermissionBlockedState() {
|
|
setGeoStatus(t("geo.status.blocked"), { error: true });
|
|
showGeoInstructions(getGeoPermissionInstructions());
|
|
setGeoActionBusy(false);
|
|
setGeoActionLabel(t("geo.btn.retry"));
|
|
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
}
|
|
|
|
export function shouldBypassGeolocationHost() {
|
|
return geoBypassHosts.includes(window.location.hostname);
|
|
}
|
|
|
|
async function checkGeoBypassByClientIp() {
|
|
try {
|
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
const timeoutId = controller ? setTimeout(() => controller.abort(), 4000) : null;
|
|
|
|
const res = await fetch(endpoints.geoBypass, {
|
|
credentials: "same-origin",
|
|
cache: "no-store",
|
|
signal: controller?.signal,
|
|
});
|
|
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
|
|
const data = await res.json();
|
|
return data.status === "success" && data.bypassGeo === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function bypassGeolocation(reason, extra = {}) {
|
|
trackEvent("geo_bypass_host", { reason, ...extra });
|
|
if (getAppAccessLevel() === "menu") {
|
|
unlockFullApp();
|
|
} else {
|
|
startApp();
|
|
}
|
|
}
|
|
|
|
async function queryGeolocationPermissionState() {
|
|
if (!navigator.permissions?.query) {
|
|
return "unknown";
|
|
}
|
|
|
|
try {
|
|
const status = await navigator.permissions.query({ name: "geolocation" });
|
|
return status.state;
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
export async function bootstrapGeolocation() {
|
|
if (shouldBypassGeolocationHost()) {
|
|
bypassGeolocation("trusted_host", { host: window.location.hostname });
|
|
return;
|
|
}
|
|
|
|
if (await checkGeoBypassByClientIp()) {
|
|
bypassGeolocation("trusted_ip");
|
|
return;
|
|
}
|
|
|
|
showGeoConsentScreen();
|
|
}
|
|
|
|
export function initGeolocation() {
|
|
if (shouldBypassGeolocationHost()) {
|
|
console.warn("Bypassing geolocation for trusted host.");
|
|
bypassGeolocation("trusted_host", { host: window.location.hostname });
|
|
return;
|
|
}
|
|
|
|
checkGeoBypassByClientIp().then((bypassByIp) => {
|
|
if (bypassByIp) {
|
|
console.warn("Bypassing geolocation for trusted client IP.");
|
|
bypassGeolocation("trusted_ip");
|
|
return;
|
|
}
|
|
initGeolocationAfterBypassChecks();
|
|
});
|
|
}
|
|
|
|
export async function initGeolocationAfterBypassChecks(options = {}) {
|
|
const geoScreen = document.getElementById("geoScreen");
|
|
const loadingScreen = document.getElementById("loadingScreen");
|
|
|
|
const localBypassHosts = ["localhost", "127.0.0.1", "192.168.20.84"];
|
|
if (window.location.protocol === "http:" && localBypassHosts.includes(window.location.hostname)) {
|
|
console.warn("Bypassing geolocation on local HTTP environment.");
|
|
bypassGeolocation("local_http", { host: window.location.hostname });
|
|
return;
|
|
}
|
|
|
|
loadingScreen?.classList.add("hidden");
|
|
geoScreen?.classList.remove("hidden");
|
|
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
|
|
if (!window.isSecureContext) {
|
|
setGeoLead(GEO_DEFAULT_LEAD());
|
|
setGeoStatus(t("geo.status.https"), { error: true });
|
|
showGeoInstructions(t("geo.hint.https"));
|
|
setGeoActionBusy(false);
|
|
setGeoActionLabel(t("geo.btn.retry"));
|
|
return;
|
|
}
|
|
|
|
if (!navigator.geolocation) {
|
|
setGeoLead(GEO_DEFAULT_LEAD());
|
|
setGeoStatus(t("geo.status.unsupported"), { error: true });
|
|
hideGeoInstructions();
|
|
setGeoActionBusy(false);
|
|
return;
|
|
}
|
|
|
|
setGeoLead(GEO_DEFAULT_LEAD());
|
|
setGeoStatus(t("geo.status.checking"), { 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(t("geo.btn.retry"));
|
|
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
setGeoStatus(
|
|
t("geo.status.outside", {
|
|
dist: Math.round(dist),
|
|
accuracy: Math.round(accuracy),
|
|
}),
|
|
{ error: true }
|
|
);
|
|
showGeoInstructions(t("geo.hint.outside"));
|
|
} catch (error) {
|
|
trackEvent("geo_check_failed", {
|
|
reason: "browser_error",
|
|
code: error.code || null,
|
|
message: String(error.message || ""),
|
|
});
|
|
setGeoActionBusy(false);
|
|
setGeoActionLabel(t("geo.btn.retry"));
|
|
configureGeoSecondaryButton(getAppAccessLevel() === "menu" ? "back_to_menu" : "menu_only");
|
|
const deniedBecauseInsecure = /secure origins|only secure|https/i.test(String(error.message || ""));
|
|
|
|
if (deniedBecauseInsecure) {
|
|
setGeoStatus(t("geo.status.https_short"), { error: true });
|
|
showGeoInstructions(t("geo.hint.https"));
|
|
} else if (isGeoPermissionDenied(error)) {
|
|
showGeoPermissionBlockedState();
|
|
} else {
|
|
setGeoStatus(t("geo.status.failed"), { error: true });
|
|
showGeoInstructions(t("geo.hint.retry"));
|
|
}
|
|
}
|
|
}
|
|
|
|
export function startGeoBootstrap() {
|
|
bindGeoScreenButtons();
|
|
|
|
if (shouldBypassGeolocationHost()) {
|
|
bypassGeolocation("trusted_host", { host: window.location.hostname });
|
|
} else {
|
|
bootstrapGeolocation();
|
|
}
|
|
}
|