Przebudowa działania geolokalizacji. Zgoda na menu bez lokalizacji.
This commit is contained in:
@@ -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