Przebudowa działania geolokalizacji. Zgoda na menu bez lokalizacji.

This commit is contained in:
2026-05-31 00:00:39 +02:00
parent 8de221ba79
commit 04aaa6e321
7 changed files with 517 additions and 28 deletions

View File

@@ -24,6 +24,16 @@ if (!is_array($data)) {
exit;
}
$payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : [];
if (
(isset($data['skipAnalytics']) && $data['skipAnalytics'] === true)
|| (isset($payload['staffPreview']) && $payload['staffPreview'] === true)
) {
echo json_encode(['status' => 'success', 'skipped' => true], JSON_UNESCAPED_UNICODE);
exit;
}
$eventName = isset($data['eventName']) ? trim((string)$data['eventName']) : '';
$sessionId = isset($data['sessionId']) ? trim((string)$data['sessionId']) : '';
$tableId = isset($data['tableId']) ? trim((string)$data['tableId']) : null;
@@ -31,7 +41,6 @@ $zone = isset($data['zone']) ? trim((string)$data['zone']) : null;
$qrHash = isset($data['qrHash']) ? trim((string)$data['qrHash']) : null;
$deviceType = isset($data['deviceType']) ? trim((string)$data['deviceType']) : null;
$browser = isset($data['browser']) ? trim((string)$data['browser']) : null;
$payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : null;
$allowedEvents = [
'qr_scan',
@@ -46,6 +55,9 @@ $allowedEvents = [
'menu_search',
'bill_dialog_opened',
'bill_request_sent',
'menu_only_entered',
'geo_gate_prompted',
'geo_retry_from_menu',
];
if ($eventName === '' || !in_array($eventName, $allowedEvents, true)) {

View File

@@ -24,7 +24,7 @@ if (!isAdminLoggedIn()) {
}
$range = isset($_GET['days']) ? trim((string)$_GET['days']) : '7';
$allowedRanges = ['7', '30', '90', 'all', 'this_year', 'last_year'];
$allowedRanges = ['today', '7', '30', '90', 'all', 'this_year', 'last_year'];
if (!in_array($range, $allowedRanges, true)) {
$range = '7';
}
@@ -36,6 +36,15 @@ $now = new DateTimeImmutable('now');
switch ($range) {
case 'all':
break;
case 'today':
$start = new DateTimeImmutable('today');
$end = $start->modify('+1 day');
$whereWindow = ' AND created_at >= :start_at AND created_at < :end_at';
$baseParams = [
':start_at' => $start->format('Y-m-d H:i:s'),
':end_at' => $end->format('Y-m-d H:i:s'),
];
break;
case 'this_year':
$start = new DateTimeImmutable(date('Y-01-01 00:00:00'));
$end = $start->modify('+1 year');
@@ -108,7 +117,7 @@ try {
COUNT(*) AS total
FROM analytics_events
WHERE 1=1 {$whereWindow}
AND event_name IN ('qr_scan','session_start','view_menu','bill_dialog_opened','bill_request_sent','waiter_call_requested')
AND event_name IN ('qr_scan','session_start','view_menu','bill_dialog_opened','bill_request_sent','waiter_call_requested','menu_only_entered','geo_gate_prompted','geo_retry_from_menu')
GROUP BY event_name
";
$stmtFunnel = $pdo->prepare($sqlFunnel);
@@ -157,7 +166,6 @@ try {
created_at,
session_id,
table_id,
zone,
device_type,
browser,
JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.ipAddress')) AS ip_address
@@ -186,6 +194,7 @@ try {
SELECT
session_id,
MAX(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS reached_app,
MAX(CASE WHEN event_name = 'menu_only_entered' THEN 1 ELSE 0 END) AS menu_only,
MAX(CASE WHEN event_name = 'view_menu' THEN 1 ELSE 0 END) AS entered_menu
FROM analytics_events
WHERE session_id IN ({$placeholders})
@@ -196,22 +205,87 @@ try {
while ($flagRow = $stmtSessionFlags->fetch()) {
$sessionOutcomes[$flagRow['session_id']] = [
'reached_app' => (int) $flagRow['reached_app'],
'menu_only' => (int) $flagRow['menu_only'],
'entered_menu' => (int) $flagRow['entered_menu'],
];
}
$sqlVisitHistory = "
SELECT session_id, created_at
FROM analytics_events
WHERE event_name = 'qr_scan'
AND session_id IN ({$placeholders})
ORDER BY session_id ASC, created_at ASC
";
$stmtVisitHistory = $pdo->prepare($sqlVisitHistory);
$stmtVisitHistory->execute($sessionIds);
$visitHistoryBySession = [];
while ($visitRow = $stmtVisitHistory->fetch()) {
$sid = trim((string) ($visitRow['session_id'] ?? ''));
if ($sid === '') {
continue;
}
if (!isset($visitHistoryBySession[$sid])) {
$visitHistoryBySession[$sid] = [];
}
$visitHistoryBySession[$sid][] = (string) $visitRow['created_at'];
}
} else {
$visitHistoryBySession = [];
}
foreach ($recentOpens as &$openRow) {
$sid = trim((string) ($openRow['session_id'] ?? ''));
$flags = $sessionOutcomes[$sid] ?? ['reached_app' => 0, 'entered_menu' => 0];
$flags = $sessionOutcomes[$sid] ?? ['reached_app' => 0, 'menu_only' => 0, 'entered_menu' => 0];
$openRow['reached_app'] = $flags['reached_app'];
$openRow['menu_only'] = $flags['menu_only'];
$openRow['entered_menu'] = $flags['entered_menu'];
$visitTimes = $visitHistoryBySession[$sid] ?? [];
$openAt = (string) ($openRow['created_at'] ?? '');
$visitNumber = 0;
foreach ($visitTimes as $visitAt) {
if ($visitAt <= $openAt) {
$visitNumber++;
}
}
if ($visitNumber < 1 && $openAt !== '') {
$visitNumber = 1;
}
$openRow['visitor_id_short'] = $sid !== '' ? substr($sid, 0, 8) : null;
$openRow['visitor_visit_number'] = $visitNumber;
$openRow['visitor_total_visits'] = count($visitTimes);
$openRow['visitor_first_seen_at'] = $visitTimes[0] ?? null;
$openRow['visitor_is_returning'] = $visitNumber > 1 ? 1 : 0;
}
unset($openRow);
$sqlVisitorSummary = "
SELECT
COUNT(DISTINCT session_id) AS unique_visitors,
SUM(CASE WHEN scan_count > 1 THEN 1 ELSE 0 END) AS returning_visitors
FROM (
SELECT session_id, COUNT(*) AS scan_count
FROM analytics_events
WHERE event_name = 'qr_scan' {$whereWindow}
GROUP BY session_id
) visitor_counts
";
$stmtVisitorSummary = $pdo->prepare($sqlVisitorSummary);
$stmtVisitorSummary->execute($baseParams);
$visitorSummaryRow = $stmtVisitorSummary->fetch() ?: [
'unique_visitors' => 0,
'returning_visitors' => 0,
];
$uniqueVisitors = (int) $visitorSummaryRow['unique_visitors'];
$returningVisitors = (int) $visitorSummaryRow['returning_visitors'];
$sqlQueueSummary = "
SELECT
COUNT(*) AS total_actions,
SUM(CASE WHEN message_type = 'waiter_call' THEN 1 ELSE 0 END) AS waiter_calls,
SUM(CASE WHEN message_type = 'bill_request' THEN 1 ELSE 0 END) AS bill_requests,
SUM(CASE WHEN api_sent = 0 THEN 1 ELSE 0 END) AS pending_api,
SUM(CASE WHEN status_kds = 0 THEN 1 ELSE 0 END) AS pending_kds,
SUM(CASE WHEN status_kds = 1 THEN 1 ELSE 0 END) AS done_kds
@@ -222,6 +296,8 @@ try {
$stmtQueueSummary->execute($baseParams);
$queueSummary = $stmtQueueSummary->fetch() ?: [
'total_actions' => 0,
'waiter_calls' => 0,
'bill_requests' => 0,
'pending_api' => 0,
'pending_kds' => 0,
'done_kds' => 0,
@@ -263,6 +339,9 @@ try {
'bill_dialog_opened' => (int)($funnelMap['bill_dialog_opened'] ?? 0),
'bill_request_sent' => (int)($funnelMap['bill_request_sent'] ?? 0),
'waiter_call_requested' => (int)($funnelMap['waiter_call_requested'] ?? 0),
'menu_only_entered' => (int)($funnelMap['menu_only_entered'] ?? 0),
'geo_gate_prompted' => (int)($funnelMap['geo_gate_prompted'] ?? 0),
'geo_retry_from_menu' => (int)($funnelMap['geo_retry_from_menu'] ?? 0),
],
'geolocation' => [
'passed' => (int)$geo['geo_passed'],
@@ -270,9 +349,15 @@ try {
'bypass' => (int)$geo['geo_bypass'],
],
'deviceStats' => $deviceStats,
'visitorSummary' => [
'uniqueVisitors' => $uniqueVisitors,
'returningVisitors' => $returningVisitors,
],
'recentOpens' => $recentOpens,
'guestQueueSummary' => [
'total' => (int)$queueSummary['total_actions'],
'waiterCalls' => (int)$queueSummary['waiter_calls'],
'billRequests' => (int)$queueSummary['bill_requests'],
'pendingApi' => (int)$queueSummary['pending_api'],
'pendingKds' => (int)$queueSummary['pending_kds'],
'doneKds' => (int)$queueSummary['done_kds'],

View File

@@ -33,6 +33,10 @@
</div>
<button id="geoActionBtn" class="btn btn-primary" style="margin-top: 24px; max-width: 250px; margin-left: auto; margin-right: auto;"
onclick="initGeolocation()">Udziel zgody / Sprawdź</button>
<p class="geo-hint" style="margin-top: 20px; font-size: 13px; color: var(--text-muted); line-height: 1.5; max-width: 320px; margin-left: auto; margin-right: auto;">
Status zamówienia, kelner i rachunek wymagają potwierdzenia, że jesteś w restauracji.
</p>
<button id="geoMenuOnlyBtn" class="btn btn-secondary" style="margin-top: 12px; max-width: 250px; margin-left: auto; margin-right: auto;">Przeglądaj menu bez lokalizacji</button>
</div>
</div>
@@ -93,6 +97,10 @@
</div> <!-- Koniec statusView -->
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner hidden">
<span>Potwierdź lokalizację, aby wezwać kelnera lub poprosić o rachunek.</span>
<button type="button" class="menu-only-banner-btn" onclick="promptGeoForFullAccess()">Sprawdź teraz</button>
</div>
<div class="menu-search-container">
<input type="text" id="menuSearchInput" placeholder="Szukaj dania..." oninput="filterMenu()" />
</div>
@@ -141,11 +149,11 @@
<span class="nav-icon">📖</span>
<span class="nav-label">Menu</span>
</div>
<div class="nav-item action-call" onclick="openWaiterDialog()">
<div class="nav-item action-call" onclick="openWaiterDialog()" id="navWaiter">
<span class="nav-icon">🛎️</span>
<span class="nav-label">Kelner</span>
</div>
<div class="nav-item action-bill" onclick="openBillDialog()">
<div class="nav-item action-bill" onclick="openBillDialog()" id="navBill">
<span class="nav-icon">💳</span>
<span class="nav-label">Rachunek</span>
</div>

View File

@@ -881,6 +881,44 @@ header {
font-weight: 500;
}
.nav-item.nav-locked {
opacity: 0.48;
filter: grayscale(0.35);
}
.nav-item.nav-locked .nav-label::after {
content: " 🔒";
font-size: 10px;
}
.menu-only-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(226, 176, 126, 0.12);
border: 1px solid rgba(226, 176, 126, 0.28);
font-size: 13px;
color: var(--text-muted);
line-height: 1.45;
}
.menu-only-banner-btn {
flex-shrink: 0;
border: 1px solid rgba(226, 176, 126, 0.45);
background: rgba(226, 176, 126, 0.15);
color: var(--primary);
border-radius: 999px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.nav-item:active .nav-icon {
transform: scale(0.9);
}

View File

@@ -9,6 +9,7 @@ window.selectedAnimationHtml = null;
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) {
@@ -22,6 +23,8 @@ if (!hashParam) {
}
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";
@@ -69,6 +72,10 @@ function deriveZoneFromTable(tableValue) {
}
function trackEvent(eventName, payload = {}) {
if (isStaffPreview) {
return;
}
const body = {
eventName,
sessionId: analyticsSessionId,
@@ -433,9 +440,155 @@ function hideLoader() {
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.toggle("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
}
}
const GEO_GATE_LABELS = {
status: "status zamówienia",
waiter: "wezwanie kelnera",
bill: "prośbę o rachunek",
};
function configureGeoSecondaryButton(mode) {
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
if (!menuOnlyBtn) return;
if (mode === "back_to_menu") {
menuOnlyBtn.style.display = "";
menuOnlyBtn.textContent = "Wróć do menu";
menuOnlyBtn.onclick = () => {
document.getElementById("geoScreen")?.classList.add("hidden");
};
return;
}
menuOnlyBtn.style.display = "";
menuOnlyBtn.textContent = "Przeglądaj menu bez lokalizacji";
menuOnlyBtn.onclick = () => enterMenuOnlyMode();
}
function showGeoGateForAction(action) {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
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ę";
if (geoMsg) {
geoMsg.innerHTML = `Aby skorzystać z <b>${feature}</b>, potwierdź, że jesteś w restauracji.<br><br>Prosimy o zgodę na dostęp do lokalizacji.`;
}
if (geoActionBtn) {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "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();
@@ -835,7 +988,13 @@ window.callWaiter = async function (type) {
showToast("Kelner wkrótce do Ciebie podejdzie!");
};
window.openWaiterDialog = async function () {
window.openWaiterDialog = function () {
requireFullAccess("waiter", () => {
openWaiterDialogInternal();
});
};
async function openWaiterDialogInternal() {
if (!(await ensureGuestActionAllowed("waiter_call"))) {
return;
}
@@ -860,7 +1019,13 @@ window.proceedToBillPayment = async function () {
goToStep("stepPayment");
};
window.openBillDialog = async function () {
window.openBillDialog = function () {
requireFullAccess("bill", () => {
openBillDialogInternal();
});
};
async function openBillDialogInternal() {
await refreshGuestPendingActions();
trackEvent("bill_dialog_opened");
billState = { payment: '', doc: '', nip: '', company: null, selectedBillId: null };
@@ -964,7 +1129,7 @@ window.goToStep = function (stepId) {
};
// --- SPA NAVIGATION LOGIC ---
window.switchTab = function (tabName) {
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'));
@@ -983,11 +1148,10 @@ window.switchTab = function (tabName) {
view.classList.add('active');
document.getElementById('navStatus').classList.add('active');
if (header) header.style.display = '';
// Show greeting banner only if it was rendered initially or has content
if (greetingBanner && greetingBanner.innerHTML.trim() !== '') greetingBanner.style.display = '';
}
else if (tabName === 'menu') {
trackEvent("view_menu");
trackEvent("view_menu", { flow: appAccessLevel === "menu" ? "menu_only" : "full_app" });
const view = document.getElementById('menuView');
view.classList.remove('hidden');
view.classList.add('active');
@@ -996,6 +1160,16 @@ window.switchTab = function (tabName) {
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 ---
@@ -1312,6 +1486,8 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
}
function startApp() {
appAccessLevel = "full";
updateNavAccessState();
document.getElementById("geoScreen").classList.add("hidden");
document.getElementById("loadingScreen").classList.remove("hidden");
trackEvent("session_start", { flow: "start_app" });
@@ -1338,6 +1514,7 @@ function showGeoConsentScreen() {
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
const geoActionBtn = document.getElementById("geoActionBtn");
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
@@ -1350,6 +1527,9 @@ function showGeoConsentScreen() {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Udziel zgody / Sprawdź";
}
if (menuOnlyBtn) {
configureGeoSecondaryButton("menu_only");
}
}
function isIOSDevice() {
@@ -1365,7 +1545,7 @@ window.initGeolocation = function () {
if (shouldBypassGeolocationHost()) {
console.warn("Bypassing geolocation for trusted host.");
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "trusted_host" });
startApp();
unlockFullApp();
return;
}
@@ -1373,12 +1553,13 @@ window.initGeolocation = function () {
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
const geoActionBtn = document.getElementById("geoActionBtn");
const menuOnlyBtn = document.getElementById("geoMenuOnlyBtn");
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.");
trackEvent("geo_bypass_host", { host: window.location.hostname, reason: "local_http" });
startApp();
unlockFullApp();
return;
}
@@ -1392,11 +1573,13 @@ window.initGeolocation = function () {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Spróbuj ponownie";
}
if (menuOnlyBtn) configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
return;
}
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
if (!navigator.geolocation) {
geoMsg.innerHTML = "Twoja przeglądarka nie wspiera geolokalizacji. Aplikacja wymaga nowszej przeglądarki.";
@@ -1422,7 +1605,7 @@ window.initGeolocation = function () {
if (dist <= MAX_DISTANCE_METERS) {
trackEvent("geo_check_passed", { distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
startApp();
unlockFullApp();
// setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000);
} else {
trackEvent("geo_check_failed", { reason: "outside_restaurant", distanceMeters: Math.round(dist), accuracyMeters: Math.round(accuracy) });
@@ -1430,6 +1613,7 @@ window.initGeolocation = function () {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "Spróbuj ponownie";
}
configureGeoSecondaryButton(appAccessLevel === "menu" ? "back_to_menu" : "menu_only");
geoMsg.innerHTML = `Wydaje się, że jesteś poza restauracją (ok. ${Math.round(dist)}m od nas).<br>Nasza aplikacja działa tylko na miejscu.<br><br>
<small style="color: #888;">Debug: Twoja odległość: ${Math.round(dist)}m, Dokładność sygnału: ${Math.round(accuracy)}m</small><br><br>
Jeśli to błąd GPS lub słaby sygnał, spróbuj ponownie za chwilę.`;
@@ -1441,6 +1625,7 @@ window.initGeolocation = function () {
geoActionBtn.disabled = false;
geoActionBtn.textContent = "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) {
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Przeglądarka zablokowała lokalizację z powodu braku HTTPS.</b><br><br>

View File

@@ -12,6 +12,12 @@ $scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/public/
$publicDir = str_replace('\\', '/', dirname($scriptDir));
$basePath = rtrim($publicDir, '/') . '/app.html?h=';
$baseUrl = "{$scheme}://{$host}{$basePath}";
$previewQuery = 'preview=staff';
function appendPreviewParam(string $link, string $previewQuery): string
{
return str_contains($link, '?') ? $link . '&' . $previewQuery : $link . '?' . $previewQuery;
}
?>
<!doctype html>
<html lang="pl">
@@ -30,6 +36,9 @@ $baseUrl = "{$scheme}://{$host}{$basePath}";
a:hover { text-decoration: underline; }
.btn-qr { padding: 8px 10px; background: #3b82f6; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
.btn-qr:hover { background: #2563eb; }
.btn-preview { display: inline-block; padding: 8px 10px; background: transparent; color: #cbd5e1; border: 1px solid #475569; border-radius: 8px; cursor: pointer; margin-left: 6px; text-decoration: none; font-size: 14px; }
.btn-preview:hover { background: #1e293b; border-color: #64748b; color: #e2e8f0; text-decoration: none; }
.actions-cell { white-space: nowrap; }
.btn-nav { display:inline-block; margin-right:8px; margin-bottom:14px; color:#cbd5e1; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); align-items: center; justify-content: center; }
.modal-content { background: #111827; border:1px solid #334155; padding: 20px; border-radius: 8px; text-align: center; max-width: 420px; width: 90%; }
@@ -42,7 +51,7 @@ $baseUrl = "{$scheme}://{$host}{$basePath}";
<a class="btn-nav" href="index.php">Wróć do panelu admina</a>
<a class="btn-nav" href="logout.php">Wyloguj</a>
<h1>Linki do aplikacji (kody QR)</h1>
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach.</p>
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach. Podgląd admina nie trafia do analityki.</p>
<table>
<tr>
<th>Nazwa stolika</th>
@@ -55,12 +64,16 @@ $baseUrl = "{$scheme}://{$host}{$basePath}";
$id = strtoupper((string)$row['ID']);
$nazwa = htmlspecialchars((string)$row['Nazwa'], ENT_QUOTES, 'UTF-8');
$link = $baseUrl . $id;
$previewLink = appendPreviewParam($link, $previewQuery);
?>
<tr>
<td><strong><?= $nazwa ?></strong></td>
<td style="font-size:.85em;color:#94a3b8;"><?= $id ?></td>
<td><a href="<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>" target="_blank"><?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?></a></td>
<td><button class="btn-qr" onclick="openQR('<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>', '<?= $nazwa ?>')">Pokaż QR</button></td>
<td class="actions-cell">
<button class="btn-qr" onclick="openQR('<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>', '<?= $nazwa ?>')">Pokaż QR</button>
<a class="btn-preview" href="<?= htmlspecialchars($previewLink, ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener">Podgląd admina</a>
</td>
</tr>
<?php endwhile; ?>
</table>

View File

@@ -27,6 +27,33 @@ requireAdminAuth(true);
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #263446; padding: 8px 6px; text-align: left; font-size: .9rem; }
th { color: #cbd5e1; }
#recentOpensBody tr.recent-open-row {
border-left: 4px solid transparent;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
#recentOpensBody tr.recent-open-row:hover {
background-color: rgba(255, 255, 255, 0.04);
}
#recentOpensBody tr.recent-open-row.session-row-highlight {
background-color: var(--session-highlight-bg, rgba(59, 130, 246, 0.18));
box-shadow: inset 0 0 0 1px var(--session-highlight-border, rgba(59, 130, 246, 0.45));
}
.visitor-cell { display: flex; align-items: flex-start; gap: 8px; }
.visitor-session-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 3px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.visitor-session-dash {
width: 4px;
min-height: 34px;
border-radius: 2px;
flex-shrink: 0;
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; background: #0b1220; color: var(--text); cursor: pointer; }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@@ -34,10 +61,14 @@ requireAdminAuth(true);
.legend-list li { margin-bottom: 6px; }
.status-ok { color: #22c55e; font-weight: 700; }
.status-fail { color: #ef4444; font-weight: 700; }
.status-menu { color: #fbbf24; font-weight: 700; }
.chart-wrap { max-width: 360px; margin: 0 auto; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 12px 20px; justify-content: center; margin-top: 12px; font-size: .88rem; color: #cbd5e1; }
.chart-legend-item { display: flex; align-items: center; gap: 6px; }
.chart-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.visitor-id { font-family: ui-monospace, Consolas, monospace; font-size: .82rem; color: #94a3b8; }
.visitor-new { color: var(--muted); font-size: .88rem; }
.visitor-return { color: #fbbf24; font-weight: 600; font-size: .88rem; }
@media (max-width: 900px) { .col-3,.col-4,.col-6,.col-8 { grid-column: span 12; } .top { flex-direction: column; align-items: flex-start; } }
</style>
</head>
@@ -57,6 +88,7 @@ requireAdminAuth(true);
<div class="card col-12" style="margin-bottom:14px;">
<div class="filters">
<button class="chip" data-days="today">Dzisiaj</button>
<button class="chip active" data-days="7">Ostatnie 7 dni</button>
<button class="chip" data-days="30">Ostatnie 30 dni</button>
<button class="chip" data-days="90">Ostatnie 90 dni</button>
@@ -70,7 +102,7 @@ requireAdminAuth(true);
<div class="card col-3"><h3>Skanowania QR</h3><div class="kpi" id="kpiScans">-</div><div class="muted">Otwarcia stron stolików</div></div>
<div class="card col-3"><h3>Sesje</h3><div class="kpi" id="kpiSessions">-</div><div class="muted">Użytkownicy, którzy weszli do aplikacji</div></div>
<div class="card col-3"><h3>Lokalizacja OK</h3><div class="kpi" id="kpiGeoPass">-</div><div class="muted">Pozytywna weryfikacja geolokalizacji</div></div>
<div class="card col-3"><h3>Przywołania kelnera</h3><div class="kpi" id="kpiWaiterCall">-</div><div class="muted">Wysłane prośby o podejście obsługi</div></div>
<div class="card col-3"><h3>Przywołania kelnera</h3><div class="kpi" id="kpiWaiterCall">-</div><div class="muted">Wpisy w kolejce gościa (KDS)</div></div>
<div class="card col-8">
<h3>Top stoliki</h3>
@@ -104,8 +136,9 @@ requireAdminAuth(true);
<div class="card col-12">
<h3>Ostatnie otwarcia stron stolików</h3>
<div class="muted" id="visitorSummaryLine" style="margin-bottom:10px;">Ładowanie podsumowania gości…</div>
<table>
<thead><tr><th>Kiedy</th><th>Stolik</th><th>Strefa</th><th>W aplikacji</th><th>Urządzenie</th><th>Przeglądarka</th><th>IP</th></tr></thead>
<thead><tr><th>Kiedy</th><th>Stolik</th><th>Gość</th><th>W aplikacji</th><th>Urządzenie</th><th>Przeglądarka</th><th>IP</th></tr></thead>
<tbody id="recentOpensBody"><tr><td colspan="7" class="muted">Ładowanie...</td></tr></tbody>
</table>
</div>
@@ -141,10 +174,12 @@ requireAdminAuth(true);
<li><strong>Skanowania QR</strong> - ile razy otwarto stronę stolika z kodu QR.</li>
<li><strong>Sesje</strong> - ile razy użytkownik faktycznie wszedł do aplikacji po starcie.</li>
<li><strong>Lokalizacja OK</strong> - ile razy weryfikacja geolokalizacji zakończyła się powodzeniem.</li>
<li><strong>Przywołania kelnera</strong> - ile razy gość poprosił o podejście obsługi.</li>
<li><strong>Przywołania kelnera (kafelek KPI)</strong> - liczba wpisów <code>waiter_call</code> w kolejce gościa (ta sama baza co tabela na dole).</li>
<li><strong>Lejek → Przywołanie kelnera (analityka kliknięć)</strong> - osobna liczba z tabeli <code>analytics_events</code>; może być wyższa po testach lub po wyczyszczeniu tylko kolejki.</li>
<li><strong>Top stoliki</strong> - które stoliki są najczęściej otwierane i używane.</li>
<li><strong>Lejek użycia</strong> - droga użytkownika: wejście -> start aplikacji -> menu -> rachunek.</li>
<li><strong>Kolumna W aplikacji (✓/✗)</strong> - ✓ oznacza przejście przez ekran lokalizacji (widok statusu zamówień); ✗ oznacza zatrzymanie na geo.</li>
<li><strong>Kolumna W aplikacji</strong> - ✓ pełna aplikacja (geo OK), M tylko menu bez lokalizacji, ✗ zatrzymanie na geo.</li>
<li><strong>Kolumna Gość</strong> - szacunkowy identyfikator urządzenia/przeglądarki (localStorage). Ten sam kolor = ta sama sesja. Kliknij wiersz, aby podświetlić wszystkie wizyty gościa.</li>
</ul>
</div>
</div>
@@ -155,6 +190,7 @@ requireAdminAuth(true);
let selectedDays = "7";
let isLoadingAnalytics = false;
let devicePieChart = null;
let selectedVisitorSessionId = null;
const deviceChartLabels = {
ios: "iPhone (iOS)",
@@ -227,6 +263,93 @@ requireAdminAuth(true);
}).join("");
}
function visitorSessionColor(sessionId) {
const id = String(sessionId || "");
if (!id) return "#64748b";
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = id.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 58%)`;
}
function hslToHighlightVars(hslColor) {
const match = String(hslColor).match(/hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)/i);
if (!match) {
return {
bg: "rgba(59, 130, 246, 0.18)",
border: "rgba(59, 130, 246, 0.45)",
};
}
const h = match[1];
const s = match[2];
const l = match[3];
return {
bg: `hsla(${h}, ${s}%, ${l}%, 0.22)`,
border: `hsla(${h}, ${s}%, ${l}%, 0.55)`,
};
}
function escapeAttr(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
}
function applyVisitorSessionHighlight(sessionId) {
document.querySelectorAll("#recentOpensBody tr.recent-open-row").forEach((row) => {
const rowSessionId = row.dataset.sessionId || "";
const isMatch = !!sessionId && rowSessionId === sessionId;
row.classList.toggle("session-row-highlight", isMatch);
if (isMatch) {
const vars = hslToHighlightVars(row.dataset.sessionColor || "");
row.style.setProperty("--session-highlight-bg", vars.bg);
row.style.setProperty("--session-highlight-border", vars.border);
} else {
row.style.removeProperty("--session-highlight-bg");
row.style.removeProperty("--session-highlight-border");
}
});
}
function bindRecentOpensRowClicks() {
const tbody = document.getElementById("recentOpensBody");
if (!tbody || tbody.dataset.clickBound === "1") return;
tbody.dataset.clickBound = "1";
tbody.addEventListener("click", (event) => {
const row = event.target.closest("tr.recent-open-row");
if (!row) return;
const sessionId = row.dataset.sessionId || "";
if (!sessionId) return;
selectedVisitorSessionId = selectedVisitorSessionId === sessionId ? null : sessionId;
applyVisitorSessionHighlight(selectedVisitorSessionId);
});
}
function formatVisitorCell(row, markColor) {
const visitNo = Number(row.visitor_visit_number || 1);
const total = Number(row.visitor_total_visits || visitNo);
const isReturning = Number(row.visitor_is_returning) === 1;
const shortId = row.visitor_id_short || "—";
const firstSeen = row.visitor_first_seen_at
? String(row.visitor_first_seen_at).replace("T", " ").slice(0, 16)
: "—";
const statusLabel = isReturning
? `<span class="visitor-return">Powrót · ${visitNo}. wizyta</span>`
: `<span class="visitor-new">Nowy · 1. wizyta</span>`;
const title = `Ten sam kolor = ta sama sesja/urządzenie · ID: ${shortId}… · Łącznie skanów: ${total} · Pierwsze wejście: ${firstSeen}`;
return `<span class="visitor-cell" title="${title}">
<span class="visitor-session-dash" style="background:${markColor}"></span>
<span>
<span style="display:inline-flex;align-items:center;gap:6px;">
<span class="visitor-session-dot" style="background:${markColor}"></span>
<span class="visitor-id" style="color:${markColor}">#${shortId}</span>
</span><br>${statusLabel}
</span>
</span>`;
}
function setChip(days) {
chips.forEach(c => c.classList.toggle("active", c.dataset.days === String(days)));
}
@@ -248,7 +371,7 @@ requireAdminAuth(true);
document.getElementById("kpiScans").textContent = n(totalScans);
document.getElementById("kpiSessions").textContent = n(totalSessions);
document.getElementById("kpiGeoPass").textContent = n(data.geolocation?.passed);
document.getElementById("kpiWaiterCall").textContent = n(data.funnel?.waiter_call_requested);
document.getElementById("kpiWaiterCall").textContent = n(data.guestQueueSummary?.waiterCalls);
const topBody = document.getElementById("topTablesBody");
const top = data.topTables || [];
@@ -265,9 +388,12 @@ requireAdminAuth(true);
const f = data.funnel || {};
document.getElementById("funnelBody").innerHTML = `
<tr><td>Otwarcie strony stolika (QR)</td><td>${n(f.qr_scan)}</td></tr>
<tr><td>Start aplikacji</td><td>${n(f.session_start)}</td></tr>
<tr><td>Start aplikacji (geo OK)</td><td>${n(f.session_start)}</td></tr>
<tr><td>Tylko menu (bez geo)</td><td>${n(f.menu_only_entered)}</td></tr>
<tr><td>Wejście do menu</td><td>${n(f.view_menu)}</td></tr>
<tr><td>Przywołanie kelnera</td><td>${n(f.waiter_call_requested)}</td></tr>
<tr><td>Próba odblokowania funkcji (geo gate)</td><td>${n(f.geo_gate_prompted)}</td></tr>
<tr><td>Ponowna geo z trybu menu</td><td>${n(f.geo_retry_from_menu)}</td></tr>
<tr><td>Przywołanie kelnera (analityka kliknięć)</td><td>${n(f.waiter_call_requested)}</td></tr>
<tr><td>Otwarcie modułu rachunku</td><td>${n(f.bill_dialog_opened)}</td></tr>
<tr><td>Wysłanie prośby o rachunek</td><td>${n(f.bill_request_sent)}</td></tr>
`;
@@ -280,6 +406,15 @@ requireAdminAuth(true);
`;
const recent = data.recentOpens || [];
const visitorSummary = data.visitorSummary || {};
const uniqueVisitors = Number(visitorSummary.uniqueVisitors || 0);
const returningVisitors = Number(visitorSummary.returningVisitors || 0);
const repeatPct = uniqueVisitors
? ((returningVisitors / uniqueVisitors) * 100).toFixed(1)
: "0";
document.getElementById("visitorSummaryLine").textContent =
`W wybranym okresie: ${n(uniqueVisitors)} unikalnych gości (urządzeń), ${n(returningVisitors)} powracających (${repeatPct}%). Kliknij wiersz, aby podświetlić wszystkie wizyty tego gościa (ten sam kolor).`;
const recentBody = document.getElementById("recentOpensBody");
recentBody.innerHTML = recent.length
? recent.map(r => {
@@ -288,13 +423,24 @@ requireAdminAuth(true);
const device = r.device_type || "-";
const browser = r.browser || "-";
const reachedApp = Number(r.reached_app) === 1;
const appCell = reachedApp
? `<span class="status-ok" title="Przeszedł lokalizację — widok statusu zamówień">✓</span>`
: `<span class="status-fail" title="Zatrzymano na ekranie lokalizacji">✗</span>`;
return `<tr><td>${when}</td><td>${r.table_id || "-"}</td><td>${r.zone || "-"}</td><td>${appCell}</td><td>${device}</td><td>${browser}</td><td>${r.ip_address || "-"}</td></tr>`;
const menuOnly = Number(r.menu_only) === 1;
let appCell;
if (reachedApp) {
appCell = `<span class="status-ok" title="Pełna aplikacja — geo OK">✓</span>`;
} else if (menuOnly) {
appCell = `<span class="status-menu" title="Tylko menu — bez lokalizacji">M</span>`;
} else {
appCell = `<span class="status-fail" title="Zatrzymano na ekranie lokalizacji">✗</span>`;
}
const markColor = visitorSessionColor(r.session_id);
const sessionId = escapeAttr(r.session_id || "");
return `<tr class="recent-open-row" data-session-id="${sessionId}" data-session-color="${markColor}" style="border-left-color:${markColor}"><td>${when}</td><td>${r.table_id || "-"}</td><td>${formatVisitorCell(r, markColor)}</td><td>${appCell}</td><td>${device}</td><td>${browser}</td><td>${r.ip_address || "-"}</td></tr>`;
}).join("")
: `<tr><td colspan="7" class="muted">Brak danych</td></tr>`;
bindRecentOpensRowClicks();
applyVisitorSessionHighlight(selectedVisitorSessionId);
renderDevicePieChart(data.deviceStats || {});
const queueSummary = data.guestQueueSummary || {};
@@ -331,6 +477,8 @@ requireAdminAuth(true);
document.getElementById("topTablesBody").innerHTML = `<tr><td colspan="5" class="muted">Nie udało się załadować analityki.</td></tr>`;
document.getElementById("zonesBody").innerHTML = `<tr><td colspan="3" class="muted">Sprawdź połączenie/API.</td></tr>`;
document.getElementById("recentOpensBody").innerHTML = `<tr><td colspan="7" class="muted">Nie udało się pobrać ostatnich otwarć.</td></tr>`;
document.getElementById("visitorSummaryLine").textContent = "Nie udało się pobrać statystyk gości.";
selectedVisitorSessionId = null;
renderDevicePieChart({});
document.getElementById("guestQueueSummaryBody").innerHTML = `<tr><td colspan="4" class="muted">Nie udało się pobrać podsumowania kolejki.</td></tr>`;
document.getElementById("guestQueueBody").innerHTML = `<tr><td colspan="8" class="muted">Nie udało się pobrać kolejki.</td></tr>`;