Przebudowa działania geolokalizacji. Zgoda na menu bez lokalizacji.
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>`;
|
||||
|
||||
Reference in New Issue
Block a user