Compare commits

...

18 Commits

Author SHA1 Message Date
83ea4184b4 Aplikacja kelnera jako PWA 2026-06-20 15:11:49 +02:00
99bb83702a Opcja przesuwania potraf w modalu szczegółow 2026-06-10 20:42:43 +02:00
79a83d4d73 Naprawa lokalizacji - odczytu 2026-06-10 20:31:48 +02:00
04aaa6e321 Przebudowa działania geolokalizacji. Zgoda na menu bez lokalizacji. 2026-05-31 00:00:39 +02:00
8de221ba79 Add device statistics to analytics reports and implement pie chart visualization in admin panel 2026-05-29 16:52:10 +02:00
9b15131461 Poprawki API i panelu admina 2026-05-29 16:27:23 +02:00
583021915a API 2026-05-29 07:44:27 +02:00
d374723fd6 Wykrywanie imienia i nazwiska kelnera oraz info czy sesja dostala sie do aplikacji 2026-05-28 20:46:35 +02:00
a72b5afcc7 Baza danych i zapis akcji przywoływania kelnera. Czekam na endpointy lub coś z KDS 2026-05-28 16:38:00 +02:00
d179256f20 Info o HotSpot 2026-05-28 09:54:48 +02:00
720aa1448b Wykluczanie IP karczmy. 2026-05-28 09:33:53 +02:00
f4ed9b3982 Walka z iphonem v2 2026-05-27 17:16:53 +02:00
67854b20a4 Wycofanie pytania o lokalizacjąna apple. 2026-05-27 17:00:19 +02:00
ef8f1472fa Komunikat o testach 2026-05-27 15:19:58 +02:00
f4fb540652 Poprawka generatora QR i ukrycie imienia 2026-05-27 15:06:53 +02:00
d54c93623f Ukrycie opcji wzywania kelnera 2026-05-27 14:46:19 +02:00
22989d9f9b Porządki v1 2026-05-27 08:47:59 +02:00
46cbf4d05b IP lokalne do białej listy 2026-05-26 09:08:17 +02:00
60 changed files with 7922 additions and 1070 deletions

5
.htaccess Normal file
View File

@@ -0,0 +1,5 @@
# Gdy ten plik leży w public_html/biesiada.menu/ (nad katalogiem app/)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^app/public/app\.html$ app/public/app.php [L,QSA]
</IfModule>

23
.vscode/sftp.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "My Server",
"host": "192.168.20.23",
"protocol": "sftp",
"port": 22,
"username": "bartek",
"password": "pelt-sweat-antiquely-ecosystem-dissuade",
"remotePath": "/home/bartek/public_html/biesiada.menu/app",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": true,
"watcher": {
"files": "**/*",
"autoUpload": true,
"autoDelete": false
},
"ignore": [
"**/.git/**",
"**/.vscode/**",
"**/.DS_Store",
"**/vendor/**"
]
}

View File

@@ -49,6 +49,26 @@ Z powodów bezpieczeństwa, aby klienci w restauracji nie podglądali zamówień
4. Upewnij się, że folder `api/cache` posiada prawa zapisu (`chmod 777` na Linuksie / Pełna kontrola na Windowsie) by skrypt mógł wygenerować `tables_cache.json`.
5. Dla ułatwienia pracy, na roocie znajduje się `index.php` (Dev Portal), z którego możesz klikać we wszystkie moduły (do usunięcia na produkcji).
## 📊 Analityka (MVP)
W projekcie działa eventowa analityka oparta o MySQL:
- Endpoint zapisu eventów: `api/analytics.php` (POST JSON).
- Endpoint raportowy pod panel: `api/analytics_reports.php` (GET, parametr `days`).
- Skrypt agregacji dziennej: `scripts/analytics_aggregate_daily.php`.
Przykładowe uruchomienie agregacji:
```bash
php scripts/analytics_aggregate_daily.php
php scripts/analytics_aggregate_daily.php 2026-05-28
```
Przykładowe raporty JSON:
- `api/analytics_reports.php?days=7`
- `api/analytics_reports.php?days=30`
## 💡 Jak działa mapowanie zamówień?
- Aplikacja KDS wyciąga dane łącząc tabele `dbo.NGastroDTRachunek` i `dbo.NGastroDTRachunekPozycja`.

169
api/analytics.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/get_table_name.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed'
], JSON_UNESCAPED_UNICODE);
exit;
}
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => 'Invalid JSON payload'
], JSON_UNESCAPED_UNICODE);
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;
$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;
$allowedEvents = [
'qr_scan',
'session_start',
'geo_check_started',
'geo_check_passed',
'geo_check_failed',
'geo_bypass_host',
'waiter_call_requested',
'view_status',
'view_menu',
'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)) {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'Invalid eventName'
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($sessionId === '' || strlen($sessionId) > 64) {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'Invalid sessionId'
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($qrHash && !$tableId && isset($conn)) {
$resolved = getTableNameByHash($conn, $qrHash);
if ($resolved !== '') {
$tableId = $resolved;
}
}
if ($tableId !== null && strlen($tableId) > 32) {
$tableId = substr($tableId, 0, 32);
}
if ($zone !== null && strlen($zone) > 16) {
$zone = substr($zone, 0, 16);
}
if ($qrHash !== null && strlen($qrHash) > 128) {
$qrHash = substr($qrHash, 0, 128);
}
if ($deviceType !== null && strlen($deviceType) > 16) {
$deviceType = substr($deviceType, 0, 16);
}
if ($browser !== null && strlen($browser) > 32) {
$browser = substr($browser, 0, 32);
}
$ip = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($parts[0]);
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
}
$ipHash = $ip !== '' ? hash('sha256', $ip . '|karczma_analytics_v1') : null;
$payload = is_array($payload) ? $payload : [];
if ($ip !== '' && !isset($payload['ipAddress'])) {
$payload['ipAddress'] = $ip;
}
$payloadJson = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
try {
$pdo = getAnalyticsPdo();
$stmt = $pdo->prepare("
INSERT INTO analytics_events (
created_at,
event_name,
session_id,
table_id,
zone,
qr_hash,
device_type,
browser,
ip_hash,
payload_json
) VALUES (
NOW(3),
:event_name,
:session_id,
:table_id,
:zone,
:qr_hash,
:device_type,
:browser,
:ip_hash,
:payload_json
)
");
$stmt->execute([
':event_name' => $eventName,
':session_id' => $sessionId,
':table_id' => $tableId,
':zone' => $zone,
':qr_hash' => $qrHash,
':device_type' => $deviceType,
':browser' => $browser,
':ip_hash' => $ipHash,
':payload_json' => $payloadJson,
]);
echo json_encode([
'status' => 'success'
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
// Best-effort analytics: return success=false, but do not crash frontend flow.
http_response_code(200);
echo json_encode([
'status' => 'error',
'message' => 'Analytics insert failed'
], JSON_UNESCAPED_UNICODE);
}

374
api/analytics_reports.php Normal file
View File

@@ -0,0 +1,374 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../public/staff/auth.php';
require_once __DIR__ . '/message_text_helper.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed'
], JSON_UNESCAPED_UNICODE);
exit;
}
if (!isAdminLoggedIn()) {
http_response_code(401);
echo json_encode([
'status' => 'error',
'message' => 'Unauthorized'
], JSON_UNESCAPED_UNICODE);
exit;
}
$range = isset($_GET['days']) ? trim((string)$_GET['days']) : '7';
$allowedRanges = ['today', '7', '30', '90', 'all', 'this_year', 'last_year'];
if (!in_array($range, $allowedRanges, true)) {
$range = '7';
}
$whereWindow = '';
$baseParams = [];
$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');
$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 'last_year':
$start = new DateTimeImmutable((date('Y') - 1) . '-01-01 00:00:00');
$end = $start->modify('+1 year');
$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;
default:
$daysInt = (int)$range;
if ($daysInt < 1) $daysInt = 1;
if ($daysInt > 3650) $daysInt = 3650;
$start = $now->modify("-{$daysInt} days");
$whereWindow = ' AND created_at >= :start_at';
$baseParams = [
':start_at' => $start->format('Y-m-d H:i:s'),
];
break;
}
try {
$pdo = getAnalyticsPdo();
$sqlTopTables = "
SELECT
COALESCE(NULLIF(table_id, ''), 'unknown') AS table_id,
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail
FROM analytics_events
WHERE 1=1 {$whereWindow}
GROUP BY COALESCE(NULLIF(table_id, ''), 'unknown')
ORDER BY qr_scans DESC, sessions DESC
LIMIT 20
";
$stmtTopTables = $pdo->prepare($sqlTopTables);
$stmtTopTables->execute($baseParams);
$topTables = $stmtTopTables->fetchAll();
$sqlByZone = "
SELECT
COALESCE(NULLIF(zone, ''), 'unknown') AS zone,
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail
FROM analytics_events
WHERE 1=1 {$whereWindow}
GROUP BY COALESCE(NULLIF(zone, ''), 'unknown')
ORDER BY sessions DESC, qr_scans DESC
";
$stmtByZone = $pdo->prepare($sqlByZone);
$stmtByZone->execute($baseParams);
$zoneStats = $stmtByZone->fetchAll();
$sqlFunnel = "
SELECT
event_name,
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','menu_only_entered','geo_gate_prompted','geo_retry_from_menu')
GROUP BY event_name
";
$stmtFunnel = $pdo->prepare($sqlFunnel);
$stmtFunnel->execute($baseParams);
$funnelRows = $stmtFunnel->fetchAll();
$funnelMap = [];
foreach ($funnelRows as $row) {
$funnelMap[$row['event_name']] = (int)$row['total'];
}
$sqlGeo = "
SELECT
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_passed,
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_failed,
SUM(CASE WHEN event_name = 'geo_bypass_host' THEN 1 ELSE 0 END) AS geo_bypass
FROM analytics_events
WHERE 1=1 {$whereWindow}
";
$stmtGeo = $pdo->prepare($sqlGeo);
$stmtGeo->execute($baseParams);
$geo = $stmtGeo->fetch() ?: ['geo_passed' => 0, 'geo_failed' => 0, 'geo_bypass' => 0];
$sqlDeviceStats = "
SELECT
COALESCE(NULLIF(device_type, ''), 'other') AS device_type,
COUNT(*) AS total
FROM analytics_events
WHERE event_name = 'qr_scan' {$whereWindow}
GROUP BY COALESCE(NULLIF(device_type, ''), 'other')
";
$stmtDeviceStats = $pdo->prepare($sqlDeviceStats);
$stmtDeviceStats->execute($baseParams);
$deviceRows = $stmtDeviceStats->fetchAll();
$deviceStats = ['ios' => 0, 'android' => 0, 'other' => 0];
foreach ($deviceRows as $row) {
$key = (string) ($row['device_type'] ?? 'other');
if (!isset($deviceStats[$key])) {
$deviceStats['other'] += (int) $row['total'];
} else {
$deviceStats[$key] = (int) $row['total'];
}
}
$sqlRecentOpens = "
SELECT
created_at,
session_id,
table_id,
device_type,
browser,
JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.ipAddress')) AS ip_address
FROM analytics_events
WHERE event_name = 'qr_scan' {$whereWindow}
ORDER BY created_at DESC
LIMIT 50
";
$stmtRecentOpens = $pdo->prepare($sqlRecentOpens);
$stmtRecentOpens->execute($baseParams);
$recentOpens = $stmtRecentOpens ? $stmtRecentOpens->fetchAll() : [];
$sessionOutcomes = [];
$sessionIds = [];
foreach ($recentOpens as $openRow) {
$sid = trim((string) ($openRow['session_id'] ?? ''));
if ($sid !== '') {
$sessionIds[$sid] = true;
}
}
$sessionIds = array_keys($sessionIds);
if (!empty($sessionIds)) {
$placeholders = implode(',', array_fill(0, count($sessionIds), '?'));
$sqlSessionFlags = "
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})
GROUP BY session_id
";
$stmtSessionFlags = $pdo->prepare($sqlSessionFlags);
$stmtSessionFlags->execute($sessionIds);
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, '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
FROM guest_action_queue
WHERE 1=1 {$whereWindow}
";
$stmtQueueSummary = $pdo->prepare($sqlQueueSummary);
$stmtQueueSummary->execute($baseParams);
$queueSummary = $stmtQueueSummary->fetch() ?: [
'total_actions' => 0,
'waiter_calls' => 0,
'bill_requests' => 0,
'pending_api' => 0,
'pending_kds' => 0,
'done_kds' => 0,
];
$sqlQueueItems = "
SELECT
id,
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
api_sent,
status_kds,
created_at
FROM guest_action_queue
WHERE 1=1 {$whereWindow}
ORDER BY created_at DESC
LIMIT 100
";
$stmtQueueItems = $pdo->prepare($sqlQueueItems);
$stmtQueueItems->execute($baseParams);
$queueItems = $stmtQueueItems->fetchAll();
foreach ($queueItems as &$queueRow) {
$queueRow['message_text'] = normalizeQueueMessageText((string) ($queueRow['message_text'] ?? ''));
}
unset($queueRow);
echo json_encode([
'status' => 'success',
'days' => $range,
'topTables' => $topTables,
'zoneStats' => $zoneStats,
'funnel' => [
'qr_scan' => (int)($funnelMap['qr_scan'] ?? 0),
'session_start' => (int)($funnelMap['session_start'] ?? 0),
'view_menu' => (int)($funnelMap['view_menu'] ?? 0),
'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'],
'failed' => (int)$geo['geo_failed'],
'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'],
],
'guestQueue' => $queueItems,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Nie udało się pobrać raportów analitycznych.'
], JSON_UNESCAPED_UNICODE);
}

View File

@@ -18,9 +18,14 @@ $tsqlBills = "
r.ID,
r.Numer,
r.Opis,
s.Nazwa as NazwaStolika
s.Nazwa as NazwaStolika,
o.Imie AS OtwierajacyImie,
o.Nazwisko AS OtwierajacyNazwisko,
o.Nick AS OtwierajacyNick
FROM dbo.NGastroDTRachunek r
LEFT JOIN dbo.NGastroStolik s ON s.ID = r.StolikID
LEFT JOIN dbo.NGastroUzytkownik u ON u.ID = r.UzytkownikOtwierajacyID
LEFT JOIN dbo.NSysOperator o ON o.ID = u.OperatorID
WHERE CAST(r.DataOtwarcia as DATE) = CAST(GETDATE() as DATE)
AND r.Status = 0
";
@@ -63,12 +68,22 @@ while ($row = sqlsrv_fetch_array($stmtBills, SQLSRV_FETCH_ASSOC)) {
if ($isMatched) {
$billId = $row['ID'];
$matchedBillIds[] = $billId;
$imie = trim((string) ($row['OtwierajacyImie'] ?? ''));
$nazwisko = trim((string) ($row['OtwierajacyNazwisko'] ?? ''));
$nick = trim((string) ($row['OtwierajacyNick'] ?? ''));
$pelneImie = trim($imie . ' ' . $nazwisko);
$bills[$billId] = [
'id' => $billId,
'numer' => $row['Numer'],
'opis' => $row['Opis'],
'suma' => 0,
'pozycje' => []
'pozycje' => [],
'otwierajacy' => [
'imie' => $imie,
'nazwisko' => $nazwisko,
'nick' => $nick,
'nazwa' => $pelneImie !== '' ? $pelneImie : $nick,
],
];
}
}

22
api/geo_bypass.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/request_ip.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
$clientIp = getRequestClientIp();
$bypass = isGeoBypassTrustedIp($clientIp);
echo json_encode([
'status' => 'success',
'bypassGeo' => $bypass,
'clientIp' => $clientIp,
], JSON_UNESCAPED_UNICODE);

359
api/guest_action_queue.php Normal file
View File

@@ -0,0 +1,359 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/get_table_name.php';
require_once __DIR__ . '/resolve_table_operator.php';
require_once __DIR__ . '/message_text_helper.php';
$kdsSecret = 'karczma_kuchnia';
function verifyKdsSecret(): bool
{
global $kdsSecret;
$secret = isset($_GET['kds_secret']) ? trim((string) $_GET['kds_secret']) : '';
return $secret === $kdsSecret;
}
function resolveGuestQueueTableId(string $tableId, string $qrHash): string
{
global $conn;
if ($tableId === '' && $qrHash !== '' && isset($conn)) {
$resolved = getTableNameByHash($conn, $qrHash);
if ($resolved !== '') {
$tableId = $resolved;
}
}
return trim($tableId);
}
function hasPendingGuestAction(PDO $pdo, string $tableId, string $messageType): bool
{
$stmt = $pdo->prepare("
SELECT 1
FROM guest_action_queue
WHERE table_id = :table_id
AND message_type = :message_type
AND status_kds = 0
LIMIT 1
");
$stmt->execute([
':table_id' => $tableId,
':message_type' => $messageType,
]);
return (bool) $stmt->fetchColumn();
}
function fetchPendingGuestActions(PDO $pdo, string $tableId): array
{
$stmt = $pdo->prepare("
SELECT message_type
FROM guest_action_queue
WHERE table_id = :table_id
AND status_kds = 0
AND message_type IN ('waiter_call', 'bill_request')
");
$stmt->execute([':table_id' => $tableId]);
$pending = [
'waiter_call' => false,
'bill_request' => false,
];
while ($row = $stmt->fetch()) {
$type = (string) ($row['message_type'] ?? '');
if (isset($pending[$type])) {
$pending[$type] = true;
}
}
return $pending;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (verifyKdsSecret()) {
try {
$pdo = getAnalyticsPdo();
$stmt = $pdo->query("
SELECT
id,
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
created_at,
updated_at
FROM guest_action_queue
WHERE status_kds = 0
ORDER BY created_at ASC
LIMIT 100
");
$rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['id'] = (int) $row['id'];
$row['message_text'] = normalizeQueueMessageText((string) ($row['message_text'] ?? ''));
}
unset($row);
echo json_encode([
'status' => 'success',
'count' => count($rows),
'polled_at' => date('Y-m-d H:i:s'),
'poll_interval_seconds' => 30,
'data' => $rows,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Queue fetch failed',
], JSON_UNESCAPED_UNICODE);
}
exit;
}
$qrHash = isset($_GET['h']) ? trim((string) $_GET['h']) : '';
$tableId = resolveGuestQueueTableId(
isset($_GET['tableId']) ? trim((string) $_GET['tableId']) : '',
$qrHash
);
if ($tableId === '') {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => 'tableId or h is required',
], JSON_UNESCAPED_UNICODE);
exit;
}
if (strlen($tableId) > 32) {
$tableId = substr($tableId, 0, 32);
}
try {
$pdo = getAnalyticsPdo();
$pending = fetchPendingGuestActions($pdo, $tableId);
echo json_encode([
'status' => 'success',
'table_id' => $tableId,
'pending' => $pending,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Pending status fetch failed',
], JSON_UNESCAPED_UNICODE);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PATCH') {
if (!verifyKdsSecret()) {
http_response_code(403);
echo json_encode([
'status' => 'error',
'message' => 'Forbidden',
], JSON_UNESCAPED_UNICODE);
exit;
}
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => 'Invalid JSON payload',
], JSON_UNESCAPED_UNICODE);
exit;
}
$id = isset($data['id']) ? (int) $data['id'] : 0;
if ($id < 1) {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'id is required',
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = getAnalyticsPdo();
$stmt = $pdo->prepare("
UPDATE guest_action_queue
SET status_kds = 1
WHERE id = :id
AND status_kds = 0
");
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode([
'status' => 'error',
'message' => 'Queue item not found or already dismissed',
], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode([
'status' => 'success',
'id' => $id,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Queue dismiss failed',
], JSON_UNESCAPED_UNICODE);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => 'Invalid JSON payload',
], JSON_UNESCAPED_UNICODE);
exit;
}
$tableId = isset($data['tableId']) ? trim((string) $data['tableId']) : '';
$messageType = isset($data['messageType']) ? trim((string) $data['messageType']) : '';
$messageText = isset($data['messageText']) ? (string) $data['messageText'] : '';
$qrHash = isset($data['qrHash']) ? trim((string) $data['qrHash']) : '';
$otwierajacyImie = isset($data['otwierajacyImie']) ? trim((string) $data['otwierajacyImie']) : '';
$otwierajacyNazwisko = isset($data['otwierajacyNazwisko']) ? trim((string) $data['otwierajacyNazwisko']) : '';
$allowedTypes = ['waiter_call', 'bill_request'];
if ($messageType === '' || !in_array($messageType, $allowedTypes, true)) {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'Invalid messageType',
], JSON_UNESCAPED_UNICODE);
exit;
}
$tableId = resolveGuestQueueTableId($tableId, $qrHash);
if ($tableId === '') {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'tableId is required',
], JSON_UNESCAPED_UNICODE);
exit;
}
$messageText = normalizeQueueMessageText($messageText);
if ($messageText === '') {
http_response_code(422);
echo json_encode([
'status' => 'error',
'message' => 'messageText is required',
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($otwierajacyImie === '' && $otwierajacyNazwisko === '' && isset($conn)) {
$operator = resolveOperatorForTable($conn, strtolower($tableId));
if ($otwierajacyImie === '' && $operator['imie'] !== '') {
$otwierajacyImie = $operator['imie'];
}
if ($otwierajacyNazwisko === '' && $operator['nazwisko'] !== '') {
$otwierajacyNazwisko = $operator['nazwisko'];
}
if ($otwierajacyImie === '' && $otwierajacyNazwisko === '' && $operator['nick'] !== '') {
$otwierajacyImie = $operator['nick'];
}
}
if (strlen($tableId) > 32) {
$tableId = substr($tableId, 0, 32);
}
if (strlen($otwierajacyImie) > 100) {
$otwierajacyImie = substr($otwierajacyImie, 0, 100);
}
if (strlen($otwierajacyNazwisko) > 100) {
$otwierajacyNazwisko = substr($otwierajacyNazwisko, 0, 100);
}
try {
$pdo = getAnalyticsPdo();
if (hasPendingGuestAction($pdo, $tableId, $messageType)) {
http_response_code(409);
echo json_encode([
'status' => 'error',
'code' => 'pending_on_kds',
'message' => $messageType === 'waiter_call'
? 'Kelner został już wezwany i czeka na obsłudze w KDS.'
: 'Prośba o rachunek jest już aktywna w KDS.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$stmt = $pdo->prepare("
INSERT INTO guest_action_queue (
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
api_sent,
status_kds,
created_at
) VALUES (
:table_id,
:message_type,
:message_text,
:otwierajacy_imie,
:otwierajacy_nazwisko,
0,
0,
NOW(3)
)
");
$stmt->execute([
':table_id' => $tableId,
':message_type' => $messageType,
':message_text' => $messageText,
':otwierajacy_imie' => $otwierajacyImie !== '' ? $otwierajacyImie : null,
':otwierajacy_nazwisko' => $otwierajacyNazwisko !== '' ? $otwierajacyNazwisko : null,
]);
echo json_encode([
'status' => 'success',
'id' => (int) $pdo->lastInsertId(),
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Queue insert failed',
], JSON_UNESCAPED_UNICODE);
}

View File

@@ -34,6 +34,9 @@ $tsql = "
r.Numer AS NumerRachunku,
r.Opis,
r.NumerReczny AS NumerRecznyRachunku,
o.Imie AS OtwierajacyImie,
o.Nazwisko AS OtwierajacyNazwisko,
o.Nick AS OtwierajacyNick,
rp.ID AS PozycjaID,
rp.StatusRealizacji,
rp.Ilosc,
@@ -51,6 +54,8 @@ $tsql = "
LEFT JOIN dbo.NGastroTowar t ON t.ID = rp.TowarID
LEFT JOIN dbo.NGastroTowar tz ON tz.ID = rp.ZestawID
LEFT JOIN dbo.NGastroStolik s ON s.ID = r.StolikID
LEFT JOIN dbo.NGastroUzytkownik u ON u.ID = r.UzytkownikOtwierajacyID
LEFT JOIN dbo.NSysOperator o ON o.ID = u.OperatorID
LEFT JOIN dbo.NGastroKonfiguracjaDrukowaniaZamowien kdz ON kdz.ID = ISNULL(rp.KonfiguracjaDrukowaniaZamowienID, t.KonfiguracjaDrukowaniaZamowienID)
WHERE r.Status = 0
AND rp.StatusRealizacji > 0

View File

@@ -0,0 +1,20 @@
<?php
/**
* Normalizuje komunikat kolejki do zwykłego tekstu (wiersze = \n w JSON).
* Starsze wpisy HTML (<br>, tagi) są konwertowane na plain text.
*/
function normalizeQueueMessageText(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
$text = preg_replace('/<br\s*\/?>/i', "\n", $text) ?? $text;
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text;
return trim($text);
}

94
api/request_ip.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
/**
* Adres IP klienta (pierwszy z X-Forwarded-For lub REMOTE_ADDR).
*/
function getRequestClientIp(): string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
}
/**
* Pojedyncze IP z pominięciem geo (zewnętrzne, dev, przykładowe hosty LAN).
*/
function getGeoBypassTrustedIps(): array
{
return [
'82.160.190.247',
'127.0.0.1',
'::1',
'192.168.20.84',
'10.0.0.3',
'10.0.0.7',
];
}
/**
* Pule wewnętrznych sieci — goście widziani przez lokalny serwer (REMOTE_ADDR z LAN).
*/
function getGeoBypassTrustedCidrs(): array
{
return [
'10.0.0.0/24',
];
}
function normalizeClientIp(string $ip): string
{
if (strpos($ip, '::ffff:') === 0) {
return substr($ip, 7);
}
return $ip;
}
function ipv4InCidr(string $ip, string $cidr): bool
{
if (!str_contains($cidr, '/')) {
return false;
}
[$subnet, $bits] = explode('/', $cidr, 2);
$bits = (int) $bits;
if ($bits < 0 || $bits > 32) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
return ($ipLong & $mask) === ($subnetLong & $mask);
}
function isGeoBypassTrustedIp(string $ip): bool
{
$ip = normalizeClientIp($ip);
if ($ip === '') {
return false;
}
if (in_array($ip, getGeoBypassTrustedIps(), true)) {
return true;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
foreach (getGeoBypassTrustedCidrs() as $cidr) {
if (ipv4InCidr($ip, $cidr)) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,74 @@
<?php
function tableNameMatchesBill(string $tableParam, string $stolikNazwa, string $opis): bool
{
if ($tableParam === '') {
return true;
}
$normalizedTableParam = str_replace('o', '0', strtolower($tableParam));
$normalizedStolikNazwa = str_replace('o', '0', strtolower($stolikNazwa));
$normalizedOpis = str_replace('o', '0', strtolower($opis));
if (
$normalizedStolikNazwa === $normalizedTableParam ||
$normalizedOpis === $normalizedTableParam
) {
return true;
}
$pattern = '/\b' . preg_quote($normalizedTableParam, '/') . '\b/i';
return (bool) (preg_match($pattern, $normalizedStolikNazwa) || preg_match($pattern, $normalizedOpis));
}
/**
* @return array{imie: string, nazwisko: string, nick: string}
*/
function resolveOperatorForTable($conn, string $tableParam): array
{
$empty = ['imie' => '', 'nazwisko' => '', 'nick' => ''];
if (!$conn || $tableParam === '') {
return $empty;
}
$tsql = "
SELECT
s.Nazwa AS NazwaStolika,
r.Opis,
o.Imie,
o.Nazwisko,
o.Nick
FROM dbo.NGastroDTRachunek r
LEFT JOIN dbo.NGastroStolik s ON s.ID = r.StolikID
LEFT JOIN dbo.NGastroUzytkownik u ON u.ID = r.UzytkownikOtwierajacyID
LEFT JOIN dbo.NSysOperator o ON o.ID = u.OperatorID
WHERE CAST(r.DataOtwarcia AS DATE) = CAST(GETDATE() AS DATE)
AND r.Status = 0
";
$stmt = sqlsrv_query($conn, $tsql);
if ($stmt === false) {
return $empty;
}
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$stolikNazwa = (string) ($row['NazwaStolika'] ?? '');
$opis = (string) ($row['Opis'] ?? '');
if (!tableNameMatchesBill($tableParam, $stolikNazwa, $opis)) {
continue;
}
sqlsrv_free_stmt($stmt);
return [
'imie' => trim((string) ($row['Imie'] ?? '')),
'nazwisko' => trim((string) ($row['Nazwisko'] ?? '')),
'nick' => trim((string) ($row['Nick'] ?? '')),
];
}
sqlsrv_free_stmt($stmt);
return $empty;
}

77
api/waiter_feed.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/message_text_helper.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'status' => 'error',
'message' => 'Method not allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = getAnalyticsPdo();
$stmt = $pdo->query("
SELECT
id,
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
status_kds,
api_sent,
created_at,
updated_at
FROM guest_action_queue
WHERE DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 500
");
$rows = $stmt->fetchAll();
$pending = 0;
$waiterCalls = 0;
$billRequests = 0;
foreach ($rows as &$row) {
$row['id'] = (int) $row['id'];
$row['status_kds'] = (int) $row['status_kds'];
$row['api_sent'] = (int) $row['api_sent'];
$row['message_text'] = normalizeQueueMessageText((string) ($row['message_text'] ?? ''));
if ($row['status_kds'] === 0) {
$pending++;
}
if ($row['message_type'] === 'waiter_call') {
$waiterCalls++;
} elseif ($row['message_type'] === 'bill_request') {
$billRequests++;
}
}
unset($row);
echo json_encode([
'status' => 'success',
'date' => date('Y-m-d'),
'count' => count($rows),
'summary' => [
'pending' => $pending,
'waiter_calls' => $waiterCalls,
'bill_requests' => $billRequests,
],
'polled_at' => date('Y-m-d H:i:s'),
'poll_interval_seconds' => 15,
'data' => $rows,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'Waiter feed fetch failed',
], JSON_UNESCAPED_UNICODE);
}

5
app/.htaccess Normal file
View File

@@ -0,0 +1,5 @@
# Gdy document root wskazuje na katalog app/ (URL: /public/app.html)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^public/app\.html$ public/app.php [L,QSA]
</IfModule>

View File

@@ -18,3 +18,30 @@ if (!$conn) {
'errors' => sqlsrv_errors()
], JSON_UNESCAPED_UNICODE));
}
// Analytics MySQL (event tracking)
$DB_HOST = '192.168.20.24';
$DB_NAME = 'karczma_stoliki';
$DB_USER = 'karczma_stoliki';
$DB_PASS = 'Reuse-Splicing-Backfire-Bouncing-Operable4';
$DB_CHARSET = 'utf8mb4';
function getAnalyticsPdo()
{
global $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS, $DB_CHARSET;
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset={$DB_CHARSET}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, $DB_USER, $DB_PASS, $options);
return $pdo;
}

View File

@@ -68,13 +68,16 @@
<p>Szybki dostęp do modułów aplikacji <strong>Karczma-Stoliki</strong>.</p>
<div class="links-grid">
<a href="public/staff/generator.php" class="dev-link">
🔗 Generator kodów QR (Kelner)
<a href="public/staff/index.php" class="dev-link">
🔐 Panel Admina (logowanie)
</a>
<a href="public/staff/kds.php" class="dev-link kds">
🍳 Ekran KDS (Kuchnia)
</a>
<a href="public/stolik2_api.html" class="dev-link client">
<a href="public/waiter/index.php" class="dev-link client">
🛎️ Panel Kelnera (wezwania)
</a>
<a href="public/app.html" class="dev-link client">
📱 Aplikacja dla Gościa (Poprosi o Hash)
</a>
</div>

1116
menu.txt Normal file

File diff suppressed because it is too large Load Diff

52
parse.js Normal file
View File

@@ -0,0 +1,52 @@
const fs = require('fs');
const html = fs.readFileSync('menu.txt', 'utf8');
const items = [];
const regexCategory = /<div class="rm-category">([\s\S]*?)<\/div>\s*<\/div>/g;
const regexTitle = /class="restaurant-menu-category">([^<]+)<\/a>/;
const regexPosition = /<div class="rmc-position" data-position="([^"]*)" data-category-id="([^"]*)" data-tag="([^"]*)">([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/g; // wait, the structure is a bit complex for simple regex.
// Let's use basic string splitting.
const cats = html.split('<div class="rm-category">');
cats.shift(); // remove first empty
const result = [];
cats.forEach(catHtml => {
const titleMatch = catHtml.match(/class="restaurant-menu-category">([^<]+)<\/a>/);
const categoryName = titleMatch ? titleMatch[1].trim() : '';
const itemsMatches = catHtml.split('<div class="rmc-position"');
itemsMatches.shift();
const categoryItems = [];
itemsMatches.forEach(itemHtml => {
const idMatch = itemHtml.match(/data-position="([^"]*)"/);
const catIdMatch = itemHtml.match(/data-category-id="([^"]*)"/);
const tagMatch = itemHtml.match(/data-tag="([^"]*)"/);
const imgMatch = itemHtml.match(/<img[^>]+src="([^"]+)"/);
const titleTextMatch = itemHtml.match(/<h4>([^<]+)/);
const descMatch = itemHtml.match(/<span>([^<]*)<\/span>/);
const priceMatch = itemHtml.match(/<div class="rmc-other">\s*<span>([^<]+)<\/span>/);
categoryItems.push({
position: idMatch ? idMatch[1] : '',
categoryId: catIdMatch ? catIdMatch[1] : '',
tag: tagMatch ? tagMatch[1] : '',
image: imgMatch ? imgMatch[1] : '',
title: titleTextMatch ? titleTextMatch[1].trim() : '',
description: descMatch ? descMatch[1].trim() : '',
price: priceMatch ? priceMatch[1].trim() : ''
});
});
result.push({
categoryName,
items: categoryItems
});
});
fs.writeFileSync('public/menu.json', JSON.stringify(result, null, 2));
console.log('Saved public/menu.json');

17
public/.htaccess Normal file
View File

@@ -0,0 +1,17 @@
# Aplikacja gościa: świeży HTML/PHP (w app.php dynamiczne ?v= z filemtime)
<IfModule mod_headers.c>
<FilesMatch "^app\.(html|php)$">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
# Nie serwuj app.html zamiast reguły rewrite / app.php
Options -MultiViews
# QR: app.html → app.php (gdy mod_rewrite działa; inaczej app.html robi redirect w JS)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^app\.html$ app.php [L,QSA]
</IfModule>

24
public/app.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Karczma Biesiada przekierowanie</title>
<script>
(function () {
var target = "app.php" + window.location.search + window.location.hash;
window.location.replace(target);
})();
</script>
</head>
<body>
<p style="font-family: sans-serif; text-align: center; margin-top: 2rem; color: #333;">
Przekierowanie do aplikacji…
<br><br>
<a href="app.php">Kliknij tutaj, jeśli nic się nie dzieje</a>
</p>
</body>
</html>

View File

@@ -1,16 +1,30 @@
<!doctype html>
<?php
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: 0');
require_once __DIR__ . '/includes/asset_version.php';
$publicDir = __DIR__;
$vCss = publicAssetVersion($publicDir, 'assets/css/app.css');
$vJs = publicAssetVersion($publicDir, 'assets/js/app.js');
$vMenu = publicAssetVersion($publicDir, 'menu.json');
?><!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<!-- build: css=<?= assetVersionAttr($vCss) ?> js=<?= assetVersionAttr($vJs) ?> -->
<title>Karczma Biesiada Twoje Zamówienie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="assets/css/stolik3_api.css">
<link rel="stylesheet" href="assets/css/app.css?v=<?= assetVersionAttr($vCss) ?>">
</head>
<body>
@@ -24,13 +38,39 @@
</div>
<div id="geoScreen" class="hidden">
<div class="geo-shell">
<div class="geo-icon">📍</div>
<div class="geo-text">
<h2>Prywatność i Lokalizacja</h2>
<div class="geo-msg" id="geoMsg">
Aby zapewnić bezpieczeństwo Twojego zamówienia, musimy upewnić się, że znajdujesz się na terenie restauracji.<br><br>Prosimy o udzielenie zgody na dostęp do lokalizacji w przeglądarce.
<h2 class="geo-title">Witamy w Karcznie</h2>
<p class="geo-lead" id="geoLead">
Przeglądaj menu od razu albo potwierdź, że jesteś u nas, aby wezwać kelnera, śledzić zamówienie i poprosić o rachunek.
</p>
<p class="geo-status" id="geoMsg"></p>
<div class="geo-actions" id="geoActions">
<button type="button" id="geoMenuOnlyBtn" class="geo-btn geo-btn-menu">
<span class="geo-btn-main">Przejdź do menu</span>
<span class="geo-btn-sub">bez lokalizacji</span>
</button>
<button type="button" id="geoActionBtn" class="geo-btn geo-btn-locate btn btn-primary">
<span class="geo-btn-main">Zgoda, sprawdź lokalizację</span>
</button>
</div>
<div class="geo-instructions hidden" id="geoInstructions" aria-live="polite"></div>
<div class="geo-wifi-callout">
<p class="geo-wifi-callout-title">📶 Wejdź bez zgody na lokalizację</p>
<p>Połącz telefon z siecią WiFi restauracji:</p>
<p class="geo-wifi-network"><strong>HotSpot Karczmy</strong></p>
<p class="geo-wifi-password">Hasło: <strong>karczmabiesiada</strong></p>
<p>Po połączeniu <strong>odśwież stronę</strong> (lub zamknij i otwórz ponownie kod QR). Aplikacja wpuści Cię <strong>bez pytania o lokalizację</strong> z pełnym dostępem do:</p>
<ul class="geo-wifi-list">
<li>wezwania kelnera</li>
<li>prośby o rachunek</li>
<li>statusu zamówienia</li>
<li>całej aplikacji przy stoliku</li>
</ul>
</div>
<button class="btn btn-primary" style="margin-top: 24px; max-width: 250px; margin-left: auto; margin-right: auto;" onclick="initGeolocation()">Udziel zgody / Sprawdź</button>
</div>
</div>
@@ -39,9 +79,9 @@
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="tableLabel" class="table-badge">Wybierz stolik</div>
</header>
<div id="greetingBanner"
<!-- <div id="greetingBanner"
style="display:none; text-align:center; padding: 10px; font-weight:600; color:var(--primary); font-family:'Playfair Display', serif; font-size:18px;">
</div>
</div> -->
<main id="mainContent">
<div id="statusView" class="view-section active">
@@ -91,6 +131,10 @@
</div> <!-- Koniec statusView -->
<div id="menuView" class="view-section hidden">
<div id="menuOnlyBanner" class="menu-only-banner is-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>
@@ -113,67 +157,8 @@
</ul>
</nav>
<div class="restaurant-menu-scroll">
<div class="rm-category" data-cat-id="1">
<div class="restaurant-menu-category">Na dobry początek</div>
<div class="rmc-positions">
<div class="rmc-position" data-position="331" data-category-id="1">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/losos-na-placku.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Wędzony łosoś na chrupkim placku ziemniaczanym<span>ze śmietaną i sosem a'la duńskim</span></h4>
</div>
<div class="rmc-other"><span>31,00</span></div>
</div>
<div class="rmc-position" data-position="11" data-category-id="1">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/smalczyk.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Skiśnięte ogórki i smolec swojej roboty z pieczywem<span></span></h4>
</div>
<div class="rmc-other"><span>28,00</span></div>
</div>
<div class="rmc-position" data-position="12" data-category-id="1">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/przystawki-Tatar-siekany.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Tatar siekany z wołowiny z piklami i pieczywem<span></span></h4>
</div>
<div class="rmc-other"><span>62,00</span></div>
</div>
</div>
</div>
<div class="rm-category" data-cat-id="2">
<div class="restaurant-menu-category">Polywki</div>
<div class="rmc-positions">
<div class="rmc-position" data-position="18" data-category-id="2">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/wielkanocne-2020/400_320_crop/Karczma-biesiada-stalowa-wola-dania-na-wielkanoc.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Żurek na wędzonce z jajkiem w chlebie<span></span></h4>
</div>
<div class="rmc-other"><span>31,00</span></div>
</div>
<div class="rmc-position" data-position="22" data-category-id="2">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/rosol-z-makaronem.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Rosół wiejski z trzech rodzajów mięs z makaronem<span></span></h4>
</div>
<div class="rmc-other"><span>19,00</span></div>
</div>
</div>
</div>
<div class="rm-category" data-cat-id="3">
<div class="restaurant-menu-category">Co przez gospodarza lubiane</div>
<div class="rmc-positions">
<div class="rmc-position" data-position="363" data-category-id="3">
<img class="rmc-image" src="https://www.karczmabiesiada.eu/cache/images/files/aktualnosci/nowe-smaki-lato-2024/400_320_crop/pyszne-jedzenie-karczma-biesiada-6.webp" alt="" loading="lazy">
<div class="rmc-title">
<h4>Schab po zbóju z ogórkiem kiszonym podany z białym sosem i ziemniakami opiekanymi<span></span></h4>
</div>
<div class="rmc-other"><span>53,00</span></div>
</div>
</div>
</div>
<div class="restaurant-menu-scroll" id="menuContainer">
<!-- Dynamiczne menu załaduje się tutaj -->
</div>
</div>
</div> <!-- Koniec menuView -->
@@ -198,11 +183,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>
@@ -226,7 +211,7 @@
</div>
</div>
<!-- NAME DIALOG -->
<!-- NAME DIALOG (Tymczasowo wyłączone)
<div class="modal-overlay" id="nameModal">
<div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">👋</div>
@@ -245,6 +230,35 @@
</div>
</div>
</div>
-->
<!-- ITEM MODAL -->
<div class="modal-overlay" id="itemModal">
<div class="modal-content item-modal-content" id="itemModalContent">
<button type="button" class="close-btn item-modal-close" onclick="closeItemModal()" aria-label="Zamknij">&times;</button>
<div class="item-modal-pane" id="itemModalPane">
<div class="item-modal-image-wrap">
<img id="itemModalImage" class="item-modal-image" src="" alt="">
<div id="itemModalImagePlaceholder" class="menu-image-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<span>Brak zdjęcia</span>
</div>
</div>
<div class="item-modal-body">
<div class="item-modal-meta">
<span id="itemModalCounter" class="item-modal-counter"></span>
</div>
<h3 id="itemModalTitle" class="item-modal-title"></h3>
<div id="itemModalPrice" class="item-modal-price"></div>
<p id="itemModalDesc" class="item-modal-desc"></p>
<button type="button" class="btn btn-secondary item-modal-close-btn" onclick="closeItemModal()">Zamknij</button>
</div>
</div>
</div>
</div>
<!-- BILL DIALOG -->
<div class="modal-overlay" id="billModal">
@@ -280,7 +294,7 @@
<div style="display:flex; gap:12px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goBackToBillList()"
id="btnBackToBills">Wróć</button>
<button class="btn btn-primary" style="flex:2;" onclick="goToStep('stepPayment')">Poproś rachunek</button>
<button class="btn btn-primary" style="flex:2;" onclick="proceedToBillPayment()">Poproś rachunek</button>
</div>
</div>
@@ -366,7 +380,11 @@
<span style="font-size:20px;"></span> <span id="toastText">Wysłano!</span>
</div>
<script src="assets/js/stolik3_api.js"></script>
<script>
window.MENU_ASSET_VERSION = <?= json_encode($vMenu, JSON_UNESCAPED_UNICODE) ?>;
window.APP_JS_VERSION = <?= json_encode($vJs, JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/app.js?v=<?= assetVersionAttr($vJs) ?>"></script>
</body>
</html>

View File

@@ -71,33 +71,191 @@ body {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
padding: 24px 20px;
text-align: center;
transition: opacity 0.5s ease, visibility 0.5s;
overflow-y: auto;
}
.geo-shell {
width: 100%;
max-width: 400px;
}
.geo-icon {
font-size: 80px;
margin-bottom: 16px;
font-size: 64px;
margin-bottom: 12px;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
50% { transform: translateY(-10px); }
}
.geo-text h2 {
.geo-title {
font-family: 'Playfair Display', serif;
margin: 0 0 12px;
margin: 0 0 10px;
color: var(--primary);
font-size: 1.65rem;
line-height: 1.2;
}
.geo-lead {
color: var(--text-muted);
font-size: 15px;
line-height: 1.45;
margin: 0;
}
.geo-status {
color: var(--text-muted);
font-size: 14px;
line-height: 1.45;
margin: 14px 0 0;
min-height: 0;
}
.geo-status:empty {
display: none;
}
.geo-status.is-error {
color: #ff8a8a;
font-weight: 600;
}
.geo-status.is-info {
color: var(--primary);
}
.geo-msg {
.geo-actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 10px;
margin-top: 20px;
width: 100%;
}
.geo-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 76px;
padding: 12px 10px;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
line-height: 1.25;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.geo-btn:active {
transform: scale(0.98);
}
.geo-btn-main {
font-size: 14px;
font-weight: 700;
}
.geo-btn-sub {
font-size: 11px;
font-weight: 500;
opacity: 0.85;
}
.geo-btn-menu {
background: rgba(255, 255, 255, 0.03);
border: 1.5px solid rgba(226, 176, 126, 0.25);
color: var(--text-muted);
font-size: 15px;
}
.geo-btn-menu .geo-btn-main {
color: var(--text-main);
}
.geo-btn-locate {
border: none;
box-shadow: 0 4px 18px rgba(226, 176, 126, 0.28);
}
.geo-btn-locate .geo-btn-main {
font-size: 13px;
line-height: 1.3;
}
.geo-btn-locate:disabled {
opacity: 0.65;
cursor: wait;
transform: none;
}
.geo-instructions {
margin-top: 16px;
padding: 14px 16px;
background: var(--surface-light);
border: 1px solid rgba(226, 176, 126, 0.2);
border-radius: 12px;
font-size: 13px;
line-height: 1.55;
color: #d1d5db;
text-align: left;
}
.geo-instructions.hidden {
display: none;
}
.geo-wifi-callout {
margin: 20px 0 0;
padding: 16px 18px;
background: rgba(226, 176, 126, 0.1);
border: 1.5px solid rgba(226, 176, 126, 0.35);
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
color: #e8e8ea;
text-align: left;
}
.geo-wifi-callout-title {
margin: 0 0 10px;
font-size: 16px;
font-weight: 700;
color: var(--primary);
}
.geo-wifi-callout p {
margin: 0 0 8px;
}
.geo-wifi-network,
.geo-wifi-password {
font-size: 15px;
color: var(--text-main);
}
.geo-wifi-list {
margin: 8px 0 0;
padding-left: 20px;
color: #d1d5db;
}
.geo-wifi-list li {
margin-bottom: 4px;
}
.geo-wifi-list li:last-child {
margin-bottom: 0;
}
@media (max-width: 360px) {
.geo-actions {
grid-template-columns: 1fr;
}
}
/* --- MAIN LAYOUT --- */
@@ -557,11 +715,13 @@ header {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.toast.active {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
/* --- KITCHEN ANIMATION --- */
@@ -869,6 +1029,58 @@ header {
filter: grayscale(0) opacity(1);
}
.nav-item.nav-action-pending {
opacity: 0.45;
}
.nav-item.nav-action-pending .nav-label::after {
content: " · w toku";
font-size: 9px;
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.is-hidden {
display: none !important;
}
.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);
}
@@ -894,6 +1106,10 @@ header {
margin-bottom: 10px;
}
#menuOnlyBanner.is-hidden + .menu-search-container {
padding-top: 0;
}
#menuSearchInput {
width: 100%;
background: var(--surface);
@@ -1041,3 +1257,157 @@ header {
font-size: 12px;
font-weight: 400;
}
/* --- ITEM MODAL --- */
.item-modal-content {
position: relative;
padding: 0;
overflow: hidden;
max-width: 380px;
touch-action: pan-y;
}
.item-modal-pane {
transition: transform 0.34s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.34s ease;
will-change: transform, opacity;
}
.item-modal-pane.is-dragging {
transition: none;
}
.item-modal-pane.is-exiting-left {
transform: translateX(-18%);
opacity: 0;
}
.item-modal-pane.is-exiting-right {
transform: translateX(18%);
opacity: 0;
}
.item-modal-pane.is-entering-from-right {
transition: none;
transform: translateX(18%);
opacity: 0;
}
.item-modal-pane.is-entering-from-left {
transition: none;
transform: translateX(-18%);
opacity: 0;
}
.item-modal-image-wrap {
position: relative;
height: 240px;
background: var(--surface-light);
}
.item-modal-image {
width: 100%;
height: 240px;
object-fit: cover;
display: block;
}
.menu-image-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.15));
}
.menu-image-placeholder span {
font-size: 13px;
letter-spacing: 0.02em;
}
.menu-image-placeholder.hidden,
.item-modal-image.hidden,
.rmc-image.hidden {
display: none;
}
.rmc-image-wrap {
position: relative;
width: 90px;
height: 90px;
flex-shrink: 0;
}
.rmc-image-wrap .menu-image-placeholder {
border-radius: 12px;
}
.rmc-image-wrap .menu-image-placeholder svg {
width: 28px;
height: 28px;
}
.rmc-image-wrap .menu-image-placeholder span {
font-size: 10px;
}
.item-modal-close {
position: absolute;
top: 10px;
right: 15px;
z-index: 12;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
font-size: 32px;
background: rgba(0, 0, 0, 0.3);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.item-modal-body {
padding: 24px;
text-align: left;
}
.item-modal-meta {
min-height: 18px;
margin-bottom: 6px;
}
.item-modal-counter {
font-size: 12px;
color: var(--text-muted);
}
.item-modal-title {
margin: 0 0 8px;
color: var(--text-main);
font-family: 'Playfair Display', serif;
font-size: 24px;
}
.item-modal-price {
color: var(--primary);
font-weight: 700;
font-size: 20px;
margin-bottom: 16px;
}
.item-modal-desc {
color: var(--text-muted);
font-size: 15px;
line-height: 1.6;
margin: 0 0 24px;
}
.item-modal-close-btn {
width: 100%;
}

2166
public/assets/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,936 +0,0 @@
window.kitchenAnimations = [
`<div class="kitchen-anim"><div class="fire-emoji">🔥</div><div class="pan-emoji">🍳</div></div>`,
`<div class="anim-run"><div class="chicken">🐓</div><div class="chef">👨‍🍳</div></div>`,
`<div class="anim-pot"><div class="chicken-head">🐓</div><div class="pot-emoji">🍲</div></div>`,
`<div class="anim-pizza"><div class="chef-pizza">👨‍🍳</div><div class="pizza-emoji">🍕</div></div>`,
`<div class="anim-pig"><div class="pig-emoji">🐷</div><div class="sweat-emoji">💦</div><div class="knife-emoji">🍴</div></div>`
];
window.selectedAnimationHtml = null;
const params = new URLSearchParams(location.search);
let hashParam = (params.get("h") || "").trim();
// Jeśli brak hasha w URL zapytaj użytkownika (np. do testów)
if (!hashParam) {
const input = prompt("Podaj bezpieczny hash stolika (wymagane):");
const trimmed = (input || "").trim();
if (trimmed) {
const newUrl = new URL(location.href);
newUrl.searchParams.set("h", trimmed);
location.replace(newUrl.toString());
}
}
let tableParam = ""; // Puste, zostanie uzupełnione przez backend
// USER PROFILE LOGIC
const userProfileKey = "karczma_user_profile";
const USER_PROFILE_EXPIRE_MS = 180 * 24 * 60 * 60 * 1000; // ~6 months
function initUserProfile() {
try {
const raw = localStorage.getItem(userProfileKey);
let profile = null;
if (raw) {
profile = JSON.parse(raw);
}
const now = Date.now();
// Check if profile exists and is valid
if (profile) {
if (profile.declined) {
// User declined in the past, don't ask again.
return;
}
// If expired, maybe we want to ask again or we could just keep it if they didn't decline.
// The user said: "trzymamy przez wiele miesięcy (odnawiamy datę przy każdej wizycie)"
if (now - profile.lastVisit > USER_PROFILE_EXPIRE_MS) {
// Profile expired. Ask again.
showNameDialog();
} else {
// Profile valid, renew date and show greeting
profile.lastVisit = now;
localStorage.setItem(userProfileKey, JSON.stringify(profile));
showGreeting(profile.name, profile.firstVisit || profile.lastVisit);
}
} else {
// No profile, ask for name
showNameDialog();
}
} catch (err) {
// If error parsing, ask again
showNameDialog();
}
}
function showNameDialog() {
const modal = document.getElementById("nameModal");
if (modal) {
modal.classList.add("active");
document.body.style.overflow = 'hidden';
}
}
function hideNameDialog() {
const modal = document.getElementById("nameModal");
if (modal) {
modal.classList.remove("active");
document.body.style.overflow = '';
}
}
window.saveUserName = function () {
const input = document.getElementById("userNameInput").value.trim();
if (input) {
const now = Date.now();
const profile = { name: input, firstVisit: now, lastVisit: now, declined: false };
localStorage.setItem(userProfileKey, JSON.stringify(profile));
hideNameDialog();
showGreeting(profile.name, profile.firstVisit);
}
};
window.declineUserName = function () {
const profile = { name: null, firstVisit: Date.now(), lastVisit: Date.now(), declined: true };
localStorage.setItem(userProfileKey, JSON.stringify(profile));
hideNameDialog();
};
function showGreeting(name, firstVisitTime) {
const banner = document.getElementById("greetingBanner");
if (banner && name) {
const isToday = new Date(firstVisitTime).toDateString() === new Date().toDateString();
if (isToday) {
banner.innerHTML = `Cześć ${name}, życzymy pysznego posiłku!`;
} else {
banner.innerHTML = `Witaj ${name}, super że do nas wracasz!`;
}
banner.style.display = "block";
}
}
// Call init is now delayed until geolocation succeeds
// initUserProfile();
// UI Elements
const loadingScreen = document.getElementById("loadingScreen");
const loaderMsg = document.getElementById("loaderMsg");
const tableLabel = document.getElementById("tableLabel");
const prepStatus = document.getElementById("prepStatus");
const progressBar = document.getElementById("progressBar");
const statusMeta = document.getElementById("statusMeta");
const itemsList = document.getElementById("itemsList");
const emptyState = document.getElementById("emptyState");
const metaFooter = document.getElementById("metaFooter");
const statusIcon = document.getElementById("statusIcon");
const storageKey = `stolik2_state_${(tableParam || "unknown").toLowerCase()}`;
const historyKey = "stolik2_global_history";
const historySection = document.getElementById("historySection");
const historyList = document.getElementById("historyList");
const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
const HOT_WINDOW_MS = 5 * 60 * 60 * 1000;
// Dynamic Loader Messages
const LOADER_MIN_MS = 10_000;
const loadStartTime = Date.now();
const msgs = ["Rozgrzewamy piece...", "Szef kuchni sprawdza składniki...", "Łączenie z sercem restauracji...", "Prawie gotowe..."];
let msgIdx = 0;
const msgInterval = setInterval(() => {
msgIdx = (msgIdx + 1) % msgs.length;
loaderMsg.textContent = msgs[msgIdx];
}, 4000);
// Initial State
if (tableParam) {
tableLabel.textContent = `Stolik ${tableParam}`;
}
function hideLoader() {
const elapsed = Date.now() - loadStartTime;
const remaining = Math.max(0, LOADER_MIN_MS - elapsed);
setTimeout(() => {
loadingScreen.classList.add("hidden");
clearInterval(msgInterval);
const bottomNav = document.getElementById("bottomNav");
if (bottomNav) {
bottomNav.style.display = "";
}
}, remaining);
}
function updateUI(bills) {
// Hide loader after minimum display time
hideLoader();
const allArticles = bills.flatMap(b => Array.isArray(b?.Articles) ? b.Articles : []);
const items = mergeWithPersistedItems(allArticles);
renderGlobalHistory();
if (items.length === 0) {
showEmptyState();
return;
}
renderItems(items);
updateStatus(bills, items);
}
function detectTableCandidates(bill) {
const remark = String(bill?.Remark || "");
const description = String(bill?.Description || "").trim();
// 1) klasyczny format: "STOLIK 9", "STOLIK 11A"
const stolikMatch = remark.match(/STOLIK\s*([0-9A-Z]+)/i);
const fromRemark = stolikMatch ? stolikMatch[1].toLowerCase() : "";
// 2) czasem numer bywa w samym Description
const fromDescription = description.toLowerCase();
return { fromRemark, fromDescription, remark: remark.toLowerCase() };
}
function showEmptyState() {
const title = document.getElementById("ordersTitle");
if (title) title.classList.add("hidden");
emptyState.classList.remove("hidden");
itemsList.innerHTML = "";
prepStatus.textContent = "Brak aktywnych zamówień";
statusIcon.textContent = "🍃";
progressBar.style.width = "0%";
statusMeta.textContent = "Zapraszamy do sprawdzenia naszego menu.";
// Historia może istnieć nawet gdy brak bieżących pozycji
renderGlobalHistory();
}
function normalizeArticleName(rawName) {
const name = String(rawName || "Pozycja");
// Usuwa gramatury typu: "300G", "250 G", "500/200/150G".
const withoutWeight = name.replace(
/\b\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)*\s*[gG]\b/g,
""
);
return withoutWeight
.replace(/\s{2,}/g, " ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim() || "Pozycja";
}
function loadPersistedItems() {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function savePersistedItems(items) {
try {
localStorage.setItem(storageKey, JSON.stringify(items));
} catch {
// brak miejsca/tryb prywatny
}
}
function loadGlobalHistory() {
try {
const raw = localStorage.getItem(historyKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveGlobalHistory(entries) {
const now = Date.now();
const cleaned = entries
.filter((e) => e && e.name && Number.isFinite(e.archivedAt))
.filter((e) => (now - e.archivedAt) <= SIX_MONTHS_MS);
try {
localStorage.setItem(historyKey, JSON.stringify(cleaned));
} catch {
// ignore storage errors
}
}
function addItemsToGlobalHistory(items, sourceTable) {
if (!items.length) return;
const now = Date.now();
const existing = loadGlobalHistory();
// zapobiegamy dokładnym duplikatom (ta sama nazwa + qty + stół blisko czasu)
const dedupWindowMs = 2 * 60 * 1000;
const toAdd = items.filter((item) => {
return !existing.some((h) =>
h.name === item.name &&
Number(h.qty) === Number(item.qty) &&
String(h.sourceTable || "") === String(sourceTable || "") &&
Math.abs((h.archivedAt || 0) - now) <= dedupWindowMs
);
}).map((item) => ({
name: item.name,
qty: item.qty,
sourceTable: sourceTable || "?",
archivedAt: now
}));
if (!toAdd.length) return;
saveGlobalHistory([...existing, ...toAdd]);
}
function renderGlobalHistory() {
const now = Date.now();
const history = loadGlobalHistory()
.filter((e) => (now - (e.archivedAt || 0)) <= SIX_MONTHS_MS)
.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
saveGlobalHistory(history);
if (!history.length) {
historySection.classList.add("hidden");
historyList.innerHTML = "";
return;
}
historySection.classList.remove("hidden");
historyList.innerHTML = "";
history.forEach((entry) => {
const dt = new Date(entry.archivedAt || Date.now());
const div = document.createElement("div");
div.className = "item-card archived ready";
div.innerHTML = `
<div class="item-info">
<span class="item-name">${entry.name}</span>
<span class="item-meta">Stolik ${entry.sourceTable || "?"}${dt.toLocaleDateString("pl-PL")} ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div class="item-qty">x${entry.qty}</div>
`;
historyList.appendChild(div);
});
}
window.clearGlobalHistory = function (e) {
if (e) e.preventDefault();
if (confirm("Czy na pewno chcesz usunąć historię swoich poprzednich zamówień?")) {
localStorage.removeItem(historyKey);
renderGlobalHistory();
}
};
function mergeWithPersistedItems(articles) {
const current = new Map();
articles.forEach(a => {
const name = normalizeArticleName(a.Name);
const todo = parseFloat(String(a.QuantityToDo || a.QuantitySet || "0").replace(",", "."));
const done = parseFloat(String(a.QuantityDone || "0").replace(",", "."));
if (!current.has(name)) {
current.set(name, { name, qty: 0, done: 0, present: true, completedByDisappear: false });
}
const curr = current.get(name);
curr.qty += Number.isFinite(todo) ? todo : 0;
curr.done += Number.isFinite(done) ? done : 0;
});
const persisted = loadPersistedItems();
const persistedMap = new Map(persisted.map(i => [i.name, i]));
const merged = [];
// Aktualnie obecne pozycje z WS
current.forEach((item) => {
merged.push({
...item,
present: true,
completedByDisappear: false,
updatedAt: Date.now()
});
});
// Pozycje, które były wcześniej, ale zniknęły z API -> przesyłamy od razu do historii globalnej
persistedMap.forEach((oldItem, name) => {
if (current.has(name)) return;
const qty = Number.isFinite(oldItem?.qty) ? oldItem.qty : 0;
// Zniknęło z bieżącego rachunku (np. rachunek został zamknięty), od razu leci do osobnego bloku historii!
addItemsToGlobalHistory([{ name, qty }], tableParam);
});
// Aktywne na górze, gotowe (zniknięte) na dole
merged.sort((a, b) => {
if (a.present !== b.present) return a.present ? -1 : 1;
return a.name.localeCompare(b.name, "pl");
});
savePersistedItems(merged);
return merged;
}
function renderItems(items) {
const title = document.getElementById("ordersTitle");
if (title) title.classList.remove("hidden");
emptyState.classList.add("hidden");
itemsList.innerHTML = "";
items.forEach((item) => {
const isReady = item.done >= item.qty && item.qty > 0;
const div = document.createElement("div");
div.className = `item-card ${isReady ? 'ready' : ''} ${item.present ? '' : 'archived'}`;
let meta = "🔥 W przygotowaniu";
if (isReady && item.completedByDisappear) {
meta = "✅ Gotowe (zrealizowane)";
} else if (isReady) {
meta = "✅ Gotowe";
}
div.innerHTML = `
<div class="item-info">
<span class="item-name">${item.name}</span>
<span class="item-meta">${meta}</span>
</div>
<div class="item-qty">x${item.qty}</div>
`;
itemsList.appendChild(div);
});
renderGlobalHistory();
}
function updateStatus(bills, items) {
let total = 0;
let done = 0;
items.forEach(i => {
total += Number.isFinite(i.qty) ? i.qty : 0;
done += Number.isFinite(i.done) ? i.done : 0;
});
const pct = total > 0 ? (done / total) * 100 : 0;
progressBar.style.width = `${pct}%`;
if (pct >= 100) {
prepStatus.textContent = "Gotowe do podania!";
statusIcon.innerHTML = "😋";
statusMeta.textContent = "Wszystkie Twoje dania opuściły już kuchnię.";
} else if (pct > 0) {
prepStatus.textContent = "Częściowo gotowe";
statusIcon.innerHTML = "🍳";
statusMeta.textContent = "Pierwsze pyszności już na Ciebie czekają!";
} else {
prepStatus.textContent = "W przygotowaniu";
if (!window.selectedAnimationHtml) {
window.selectedAnimationHtml = window.kitchenAnimations[Math.floor(Math.random() * window.kitchenAnimations.length)];
}
statusIcon.innerHTML = window.selectedAnimationHtml;
statusMeta.textContent = "Twoje zamówienie jest właśnie tworzone przez naszych kucharzy.";
}
// Footer meta
const newest = [...bills].sort((a, b) => new Date(b?.Date || 0) - new Date(a?.Date || 0))[0];
const time = newest?.Date
? new Date(newest.Date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: "--:--";
metaFooter.textContent = `Zamówienie złożone o godzinie ${time} • Stolik ${tableParam}`;
}
// API Fetch Logic
async function fetchOrders() {
try {
if (!hashParam) {
updateUI([]);
return;
}
const response = await fetch(`../api/kds.php?h=${encodeURIComponent(hashParam)}`);
const result = await response.json();
if (result.status === 'success') {
if (result.tableName && result.tableName !== '') {
tableLabel.textContent = `Stolik ${result.tableName}`;
tableParam = result.tableName; // Aktualizacja do właściwej nazwy na poczet innych zapytań
}
// API teraz samo filtruje i zwraca tylko to co nas interesuje (za pomocą mocnego wyrażenia regularnego)
const matches = result.data;
// Grupowanie składników w główne dania
const groups = {};
matches.forEach(item => {
const groupId = item.GrupaZestawuID || item.PozycjaID;
if (!groups[groupId]) {
groups[groupId] = {
Name: item.NazwaZestawu || item.NazwaTowaru,
QuantitySet: item.GrupaZestawuID ? 1 : parseFloat(item.Ilosc),
Done: 0
};
}
// StatusRealizacji >= 2 oznacza, że kucharz wcisnął "Gotowe" na swoim ekranie
if (parseInt(item.StatusRealizacji, 10) >= 2) {
groups[groupId].Done = groups[groupId].QuantitySet;
}
});
const transformedArticles = Object.values(groups).map(g => ({
Name: g.Name,
QuantitySet: g.QuantitySet,
QuantityDone: g.Done
}));
// Najnowszy czas dodania (do pokazania w stopce)
const latestDate = matches.length > 0
? matches.sort((a, b) => new Date(b.DataDodania) - new Date(a.DataDodania))[0].DataDodania
: null;
// Przekazanie do dotychczasowej logiki aktualizującej UI (w odpowiednim formacie)
updateUI([{
Articles: transformedArticles,
Date: latestDate
}]);
} else {
loaderMsg.textContent = "Błąd API: " + result.message;
}
} catch (err) {
loaderMsg.textContent = "Problem z połączeniem. Próbujemy ponownie...";
}
}
// fetchOrders();
// setInterval(fetchOrders, 10000);
// --- CALL WAITER LOGIC ---
let billState = { payment: '', doc: '', nip: '', company: null };
function showToast(msg) {
const t = document.getElementById("toastMsg");
document.getElementById("toastText").textContent = msg;
t.classList.remove("active");
void t.offsetWidth; // trigger reflow
t.classList.add("active");
setTimeout(() => t.classList.remove("active"), 3500);
}
function sendApiSimulated(actionName, details) {
console.log(`[SYMULACJA API] Akcja: ${actionName}`, details);
// Przykładowe wysłanie docelowo:
// if (window.socket && window.socket.readyState === WebSocket.OPEN) {
// window.socket.send(JSON.stringify({ action: "sendUpstream", payload: { type: actionName, table: tableParam, ...details } }));
// }
}
window.callWaiter = function (type) {
if (type === 'order') {
sendApiSimulated("CallWaiter_Order", { table: tableParam });
showToast("Kelner wkrótce do Ciebie podejdzie!");
}
};
window.openWaiterDialog = function () {
document.getElementById("waiterModal").classList.add("active");
document.body.style.overflow = 'hidden';
};
window.closeWaiterDialog = function () {
document.getElementById("waiterModal").classList.remove("active");
document.body.style.overflow = '';
};
window.confirmCallWaiter = function () {
closeWaiterDialog();
callWaiter('order');
};
window.openBillDialog = async function () {
billState = { payment: '', doc: '', nip: '', company: null, selectedBillId: null };
document.getElementById("billModal").classList.add("active");
document.body.style.overflow = 'hidden'; // Zablokuj scroll tła
document.getElementById("billLoading").classList.remove("hidden");
document.getElementById("billListContainer").classList.add("hidden");
goToStep("stepBillList");
try {
const res = await fetch(`../api/bills.php?h=${encodeURIComponent(hashParam)}`);
const result = await res.json();
if (result.status === 'success' && result.data.length > 0) {
const bills = result.data;
if (bills.length === 1) {
showBillReview(bills[0]);
document.getElementById("btnBackToBills").style.display = 'none';
} else {
renderBillList(bills);
document.getElementById("btnBackToBills").style.display = 'block';
}
} else {
document.getElementById("billLoading").innerHTML = "Brak otwartych rachunków do opłacenia.";
}
} catch (err) {
document.getElementById("billLoading").innerHTML = "Błąd pobierania rachunków.";
}
};
function renderBillList(bills) {
document.getElementById("billLoading").classList.add("hidden");
document.getElementById("billListContainer").classList.remove("hidden");
const container = document.getElementById("billListItems");
container.innerHTML = "";
bills.forEach(b => {
const div = document.createElement("div");
div.className = "option-card";
div.style.flexDirection = "row";
div.style.justifyContent = "space-between";
div.style.padding = "15px";
div.onclick = () => showBillReview(b);
const numerFormat = b.numer ? `#${b.numer}` : "Rachunek";
div.innerHTML = `
<div>
<div style="font-weight:bold;">${numerFormat}</div>
<div style="font-size:12px; color:var(--text-muted);">${b.opis}</div>
</div>
<div style="font-weight:bold; color:var(--primary);">${b.suma.toFixed(2)} PLN</div>
`;
container.appendChild(div);
});
}
window.goBackToBillList = function () {
goToStep("stepBillList");
};
window.showBillReview = function (bill) {
billState.selectedBillId = bill.id;
const content = document.getElementById("billReviewContent");
content.innerHTML = "";
bill.pozycje.forEach(p => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.marginBottom = "8px";
div.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
div.style.paddingBottom = "8px";
div.innerHTML = `
<div style="flex:1;">
<div style="font-weight:600; font-size: 14px;">${p.nazwa}</div>
<div style="font-size:12px; color:var(--text-muted);">${p.ilosc} x ${p.cena.toFixed(2)} PLN</div>
</div>
<div style="font-weight:600;">${p.wartosc.toFixed(2)} PLN</div>
`;
content.appendChild(div);
});
document.getElementById("billTotalAmount").textContent = bill.suma.toFixed(2) + " PLN";
goToStep("stepBillReview");
};
window.closeBillDialog = function () {
document.getElementById("billModal").classList.remove("active");
document.body.style.overflow = ''; // Odblokuj scroll tła
};
window.goToStep = function (stepId) {
document.querySelectorAll('.step').forEach(el => el.classList.remove('active'));
document.getElementById(stepId).classList.add('active');
};
// --- SPA NAVIGATION LOGIC ---
window.switchTab = function (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'));
document.getElementById('navStatus').classList.remove('active');
document.getElementById('navMenu').classList.remove('active');
const header = document.getElementById('mainHeader');
const greetingBanner = document.getElementById('greetingBanner');
// 2. Nadaj .active wybranym elementom
if (tabName === 'status') {
const view = document.getElementById('statusView');
view.classList.remove('hidden');
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') {
const view = document.getElementById('menuView');
view.classList.remove('hidden');
view.classList.add('active');
document.getElementById('navMenu').classList.add('active');
if (header) header.style.display = 'none';
if (greetingBanner) greetingBanner.style.display = 'none';
}
window.scrollTo(0, 0);
};
// --- MENU LOGIC ---
window.filterMenu = function () {
const query = document.getElementById('menuSearchInput').value.toLowerCase();
const categories = document.querySelectorAll('.rm-category');
categories.forEach(category => {
let hasVisibleItems = false;
const items = category.querySelectorAll('.rmc-position');
items.forEach(item => {
const title = item.querySelector('.rmc-title h4').textContent.toLowerCase();
if (title.includes(query)) {
item.style.display = '';
hasVisibleItems = true;
} else {
item.style.display = 'none';
}
});
if (hasVisibleItems) {
category.style.display = '';
} else {
category.style.display = 'none';
}
});
};
window.showCategory = function (categoryId) {
document.querySelectorAll('.menu-categories-nav a').forEach(a => a.classList.remove('active'));
const clickedLink = document.querySelector(`.menu-categories-nav a[data-category-badge="${categoryId}"]`);
if (clickedLink) {
clickedLink.classList.add('active');
clickedLink.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
const categories = document.querySelectorAll('.rm-category');
categories.forEach(cat => {
if (categoryId === 0) {
cat.style.display = '';
} else {
const catId = parseInt(cat.getAttribute('data-cat-id'), 10);
if (catId === categoryId) {
cat.style.display = '';
} else {
cat.style.display = 'none';
}
}
});
};
window.selectPayment = function (method) {
billState.payment = method;
goToStep("stepDocument");
};
window.selectDocument = function (docType) {
billState.doc = docType;
if (docType === 'paragon') {
closeBillDialog();
sendApiSimulated("CallWaiter_Bill", { table: tableParam, billId: billState.selectedBillId, payment: billState.payment, doc: 'paragon' });
showToast("Kelner przyniesie paragon do opłacenia!");
} else {
goToStep("stepNIP");
document.getElementById("nipInput").value = '';
setTimeout(() => document.getElementById("nipInput").focus(), 100);
}
};
window.fetchGUS = function () {
const nip = document.getElementById("nipInput").value.replace(/[\s-]/g, '');
if (nip.length < 10) {
alert("Wprowadź poprawny numer NIP.");
return;
}
const btn = document.getElementById("btnGUS");
btn.textContent = "Szukam...";
btn.disabled = true;
// Symulacja pobrania z GUS
setTimeout(() => {
btn.textContent = "Pobierz z GUS";
btn.disabled = false;
billState.nip = nip;
billState.company = {
name: "Przykładowa Firma Sp. z o.o.",
street: "ul. Gastronomiczna 12/4",
zip: "00-120",
city: "Warszawa",
nip: nip
};
document.getElementById("cmpName").value = billState.company.name;
document.getElementById("cmpStreet").value = billState.company.street;
document.getElementById("cmpZip").value = billState.company.zip;
document.getElementById("cmpCity").value = billState.company.city;
document.getElementById("cmpNip").value = "NIP: " + billState.company.nip;
// reset do readonly
document.getElementById("cmpName").readOnly = true;
document.getElementById("cmpStreet").readOnly = true;
document.getElementById("cmpZip").readOnly = true;
document.getElementById("cmpCity").readOnly = true;
document.getElementById("btnEditCompany").textContent = "Popraw ręcznie";
goToStep("stepVerify");
}, 1200);
};
window.editCompanyData = function () {
const n = document.getElementById("cmpName");
const s = document.getElementById("cmpStreet");
const z = document.getElementById("cmpZip");
const c = document.getElementById("cmpCity");
const btn = document.getElementById("btnEditCompany");
if (n.readOnly) {
n.readOnly = false;
s.readOnly = false;
z.readOnly = false;
c.readOnly = false;
n.focus();
btn.textContent = "Zakończ edycję";
} else {
n.readOnly = true;
s.readOnly = true;
z.readOnly = true;
c.readOnly = true;
btn.textContent = "Popraw ręcznie";
}
};
window.confirmInvoice = function () {
billState.company.name = document.getElementById("cmpName").value;
billState.company.street = document.getElementById("cmpStreet").value;
billState.company.zip = document.getElementById("cmpZip").value;
billState.company.city = document.getElementById("cmpCity").value;
closeBillDialog();
sendApiSimulated("CallWaiter_Bill", {
table: tableParam,
billId: billState.selectedBillId,
payment: billState.payment,
doc: 'faktura',
nip: billState.nip,
company: billState.company
});
showToast("Dziękujemy! Prośba o fakturę została wysłana.");
};
// --- GEOLOCATION LOGIC ---
const RESTAURANT_LAT = 50.5624963;
const RESTAURANT_LNG = 22.0608059;
const MAX_DISTANCE_METERS = 200;
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3;
const p1 = lat1 * Math.PI / 180;
const p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp / 2) * Math.sin(dp / 2) +
Math.cos(p1) * Math.cos(p2) *
Math.sin(dl / 2) * Math.sin(dl / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function startApp() {
document.getElementById("geoScreen").classList.add("hidden");
document.getElementById("loadingScreen").classList.remove("hidden");
initUserProfile();
fetchOrders();
if (!window.ordersInterval) {
window.ordersInterval = setInterval(fetchOrders, 10000);
}
// Fallback: If no data after 25s, show empty state anyway
setTimeout(() => {
if (!document.getElementById("loadingScreen").classList.contains("hidden")) {
updateUI([]);
}
}, 25000);
}
window.initGeolocation = function () {
const geoScreen = document.getElementById("geoScreen");
const loadingScreen = document.getElementById("loadingScreen");
const geoMsg = document.getElementById("geoMsg");
if (window.location.protocol === 'http:' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
console.warn("Bypassing geolocation on local HTTP environment.");
startApp();
return;
}
loadingScreen.classList.add("hidden");
geoScreen.classList.remove("hidden");
if (!navigator.geolocation) {
geoMsg.innerHTML = "Twoja przeglądarka nie wspiera geolokalizacji. Aplikacja wymaga nowszej przeglądarki.";
return;
}
geoMsg.innerHTML = "Pobieranie lokalizacji...";
navigator.geolocation.getCurrentPosition(
(position) => {
const dist = haversineDistance(
RESTAURANT_LAT, RESTAURANT_LNG,
position.coords.latitude, position.coords.longitude
);
const accuracy = position.coords.accuracy;
console.log(`[GEO] Lat: ${position.coords.latitude}, Lng: ${position.coords.longitude}, Dist: ${dist}m, Accuracy: ${accuracy}m`);
if (dist <= MAX_DISTANCE_METERS) {
startApp();
setTimeout(() => showToast(`Lokalizacja zweryfikowana (Dystans: ${Math.round(dist)}m, Dokładność: ${Math.round(accuracy)}m)`), 2000);
} else {
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ę.`;
}
},
(error) => {
if (error.code === error.PERMISSION_DENIED) {
geoMsg.innerHTML = `<b style="color: #ff6b6b;">Nie mamy Twojej zgody na lokalizację.</b><br><br>
Przeglądarka zapamiętała Twoją odmowę i nie możemy ponownie wyświetlić okienka z zapytaniem. Aby odblokować dostęp:<br><br>
1. Kliknij ikonkę <b>kłódki / ustawień</b> 🔒 obok adresu strony na samej górze przeglądarki.<br>
2. Znajdź <b>Uprawnienia</b> (Lokalizacja) i zmień z "Zablokuj" na "Zezwalaj".<br>
3. Odśwież stronę.`;
} else {
geoMsg.innerHTML = "Nie udało się pobrać lokalizacji. Sprawdź zasięg lub włącz GPS i spróbuj ponownie.";
}
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
};
setTimeout(() => {
initGeolocation();
}, 600);

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Wersja statycznego pliku w public/ — timestamp modyfikacji (cache busting po FTP).
*/
function publicAssetVersion(string $publicDir, string $relativePath): string
{
$path = $publicDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/'));
if (!is_file($path)) {
return '0';
}
$mtime = filemtime($path);
return $mtime !== false ? (string) $mtime : '0';
}
function assetVersionAttr(string $version): string
{
return htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
}

828
public/menu.json Normal file
View File

@@ -0,0 +1,828 @@
[
{
"categoryName": "Na dobry początek",
"items": [
{
"position": "331",
"categoryId": "1",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/losos-na-placku.webp",
"title": "Wędzony łosoś na chrupkim placku ziemniaczanym",
"description": "ze śmietaną i sosem a'la duńskim",
"price": "31,00"
},
{
"position": "11",
"categoryId": "1",
"tag": "kiszone,smalec",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/smalczyk.webp",
"title": "Skiśnięte ogórki i smolec swojej roboty z pieczywem",
"description": "",
"price": "28,00"
},
{
"position": "12",
"categoryId": "1",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/przystawki-Tatar-siekany.webp",
"title": "Tatar siekany z wołowiny z piklami i pieczywem",
"description": "",
"price": "62,00"
},
{
"position": "332",
"categoryId": "1",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/sledz-w-oleju.webp",
"title": "Śledź w oleju z kwaśnym dżemem morelowo-żurawinowym podany z pieczywem",
"description": "",
"price": "29,00"
},
{
"position": "260",
"categoryId": "1",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/oscypki.webp",
"title": "Grillowane serki góralskie",
"description": "z żurawiną z Podhala",
"price": "21,00"
},
{
"position": "187",
"categoryId": "1",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/rydze-z-cebula-na-grzance.webp",
"title": "Rydze z cebulą na grzance",
"description": "",
"price": "33,00"
}
]
},
{
"categoryName": "Polywki",
"items": [
{
"position": "18",
"categoryId": "2",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/wielkanocne-2020/400_320_crop/Karczma-biesiada-stalowa-wola-dania-na-wielkanoc.webp",
"title": "Żurek na wędzonce z jajkiem w chlebie",
"description": "",
"price": "31,00"
},
{
"position": "22",
"categoryId": "2",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/rosol-z-makaronem.webp",
"title": "Rosół wiejski z trzech rodzajów mięs z makaronem",
"description": "",
"price": "19,00"
},
{
"position": "25",
"categoryId": "2",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/zupy-Obszar-kompozycji-5.webp",
"title": "Barszcz czerwony ze swojskimi uszkami",
"description": "",
"price": "26,00"
},
{
"position": "374",
"categoryId": "2",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Krem z pomidorów z mozzarellą",
"description": "",
"price": "22,00"
},
{
"position": "377",
"categoryId": "2",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Kwaśnica z żeberkiem",
"description": "",
"price": "26,00"
}
]
},
{
"categoryName": "Co przez gospodarza lubiane, a i Wam polecane",
"items": [
{
"position": "363",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/aktualnosci/nowe-smaki-lato-2024/400_320_crop/pyszne-jedzenie-karczma-biesiada-6.webp",
"title": "Schab po zbóju z ogórkiem kiszonym podany z białym sosem i ziemniakami opiekanymi",
"description": "",
"price": "53,00"
},
{
"position": "324",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-Roladki-z-suszonymi-pomidorami.webp",
"title": "Kurczak z mozarellą i suszonymi pomidorami",
"description": "zawinięty boczkiem z dipem buraczkowym podany z kopytkami",
"price": "41,00"
},
{
"position": "379",
"categoryId": "",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Polędwiczki w sosie kurkowym z kopytkami i ogórkiem kiszonym",
"description": "",
"price": "54,00"
},
{
"position": "188",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/kaczka.webp",
"title": "Soczysta pierś z kaczki",
"description": "z sosem porzeczkowym z kopytkami i bukietem surówek",
"price": "69,00"
},
{
"position": "351",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/nowosci-2024/400_320_crop/1-1.webp",
"title": "Stek z polędwicy wołowej",
"description": "z puree ziemniaczanym i sosem z zielonego pieprzu",
"price": "99,00"
},
{
"position": "238",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/syte-koryto.webp",
"title": "Syte koryto (micha mięs na stół)",
"description": "W zestawie podwójna porcja: golonki, karkówki, schabu z kością, kiełbaski baraniej, szaszłyka, bekonu, ziemniaków opiekanych i kapusty zasmażanej,",
"price": "275,00"
}
]
},
{
"categoryName": "Dania mięsne",
"items": [
{
"position": "325",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-karczek-grillowany.webp",
"title": "Karczek grillowany z sosem BBQ",
"description": "z ziemniakami opiekanymi i sałatką wiosenną",
"price": "51,00"
},
{
"position": "272",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-filet-w-sosie-smietanowo_.webp",
"title": "Filet drobiowy w sosie śmietanowo-koperkowym",
"description": "z ziemniakami gotowanymi i bukietem surówek",
"price": "42,00"
},
{
"position": "273",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/filet.webp",
"title": "Filet z kurczaka panierowany",
"description": "z frytkami i surówką z białej kapusty",
"price": "39,00"
},
{
"position": "274",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-schabowy2.webp",
"title": "Kotlet schabowy panierowany",
"description": "z ziemniakami opiekanymi i kapustą zasmażaną",
"price": "40,00"
},
{
"position": "326",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-golonka1.webp",
"title": "Golonka po chłopsku z rusztu",
"description": "(z kością) z ziemniakami opiekanymi i kapustą zasmażaną",
"price": "58,00"
},
{
"position": "364",
"categoryId": "3",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/aktualnosci/nowe-smaki-lato-2024/400_320_crop/pyszne-jedzenie-karczma-biesiada-8.webp",
"title": "Żeberka pieczone podane na kapuście zasmażanej z ziemniakami opiekanymi",
"description": "",
"price": "56,00"
}
]
},
{
"categoryName": "To co w wodzie pływo",
"items": [
{
"position": "39",
"categoryId": "5",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/pstrag_nowy.webp",
"title": "Pstrąg pieczony",
"description": "z warzywami sezonowymi i ziemniakami",
"price": "57,00"
},
{
"position": "284",
"categoryId": "5",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/morszczuk.webp",
"title": "Morszczuk w panierce",
"description": "z frytkami i surówką z marchwii i ananasa",
"price": "40,00"
},
{
"position": "352",
"categoryId": "",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/sandacz-na-sosie-cytrynowym-karczma-biesiada-stalowa-wola.webp",
"title": "Sandacz podany na sosie śmietanowo-cytrynowym",
"description": "z zapiekanką ziemniaczaną i warzywami sezonowymi",
"price": "75,00"
}
]
},
{
"categoryName": "Dania swojskie",
"items": [
{
"position": "45",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/miesa-placek-po-wiejsku.webp",
"title": "Placek po wiejsku z gulaszem",
"description": "i surówką z białej kapusty",
"price": "40,00"
},
{
"position": "46",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/nalesniki-ze-szpinakiem.webp",
"title": "Naleśniki ze szpinakiem",
"description": "",
"price": "27,00"
},
{
"position": "47",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/pierogi-ruskie.webp",
"title": "Pierogi ruskie",
"description": "z omastą lub z patelni",
"price": "27,00"
},
{
"position": "365",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/aktualnosci/nowe-smaki-lato-2024/400_320_crop/pyszne-jedzenie-karczma-biesiada-3.webp",
"title": "Pierogi z oscypkiem, żurawiną i chipsami z cebulki",
"description": "",
"price": "31,00"
},
{
"position": "353",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/pierogi-z-miesem.webp",
"title": "Pierogi z mięsem z omastą lub z patelni",
"description": "",
"price": "29,00"
},
{
"position": "51",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/golabki-w-sosie-pomidorowym.webp",
"title": "Gołąbki w sosie pomidorowym",
"description": "",
"price": "31,00"
},
{
"position": "52",
"categoryId": "4",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/placki_z_wedzonka.webp",
"title": "Placki ziemniaczane ze śmietaną",
"description": "",
"price": "31,00"
}
]
},
{
"categoryName": "Makarony",
"items": [
{
"position": "373",
"categoryId": "6",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Makaron tagliatelle z kurczakiem i szpinakiem w sosie śmietanowym",
"description": "",
"price": "41,00"
},
{
"position": "355",
"categoryId": "6",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/nowosci-2024/400_320_crop/5.webp",
"title": "Makaron tagliatelle z podgrzybkami, gorgonzolą i boczkiem",
"description": "",
"price": "44,00"
}
]
},
{
"categoryName": "Sałatki",
"items": [
{
"position": "321",
"categoryId": "7",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/Salatka-z-ananasem-grillowanym-kurczakiem.webp",
"title": "Sałatka z grillowanym kurczakiem i ananasem",
"description": "Kurczak, ananas, sałata lodowa, jajo, ogórek, pomidor, sos ogrodowy, sos słodkie chili",
"price": "39,00"
},
{
"position": "320",
"categoryId": "7",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/salatka-grecka.webp",
"title": "Sałatka Grecka",
"description": "Ser favita, oliwki, cebula, ogórek, pomidor, sałata lodowa, sos ogrodowy",
"price": "37,00"
},
{
"position": "322",
"categoryId": "7",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/salatka-cezar.webp",
"title": "Sałatka Cezar",
"description": "Z kurczakiem, sosem czosnkowym, sałatą lodową, ser corregio i grzankami",
"price": "44,00"
}
]
},
{
"categoryName": "Burgery",
"items": [
{
"position": "361",
"categoryId": "",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/Burger-wolowy-karczma-biesiada-stalowa-wola.webp",
"title": "Buła z wołowiną",
"description": "",
"price": "41,00"
},
{
"position": "362",
"categoryId": "",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/nowosci-2024/400_320_crop/3.webp",
"title": "Buła z kurczakiem",
"description": "",
"price": "38,00"
}
]
},
{
"categoryName": "Dania dla dzieci",
"items": [
{
"position": "287",
"categoryId": "9",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/rosol-dla-dzieci.webp",
"title": "Rosół z makaronem",
"description": "porcja dziecięca",
"price": "14,00"
},
{
"position": "293",
"categoryId": "9",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/dzieci-nuggetsy-z-kurczaka.webp",
"title": "Nuggetsy z kurczaka",
"description": "z frytkami i surówką z marchwii i ananasa",
"price": "33,00"
},
{
"position": "294",
"categoryId": "9",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/filet-z-frytkami-dla-dzieci.webp",
"title": "Filet z kurczaka z frytkami i bukietem surówek",
"description": "porcja dziecięca",
"price": "27,00"
},
{
"position": "295",
"categoryId": "9",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/morszczuk-w-panierce-dla-dzieci.webp",
"title": "Morszczuk w panierce",
"description": "z ziemniakami i surówką z białej kapusty (porcja dziecięca)",
"price": "27,00"
},
{
"position": "296",
"categoryId": "9",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/filet-w-sosie-dla-dzieci.webp",
"title": "Filet w sosie śmietanowo-koperkowym",
"description": "z ziemniakami gotowanymi (porcja dziecięca)",
"price": "28,00"
},
{
"position": "298",
"categoryId": "9",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/nalesniki.webp",
"title": "Naleśniki z serem",
"description": "",
"price": "24,00"
},
{
"position": "299",
"categoryId": "9",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/dzieci-Nalesniki-z-nutella.webp",
"title": "Naleśniki z nutellą",
"description": "",
"price": "28,00"
}
]
},
{
"categoryName": "Dodatki do drugich dań",
"items": [
{
"position": "56",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/DSC088432.webp",
"title": "Ziemniaki gotowane",
"description": "",
"price": "10,00"
},
{
"position": "57",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/dodatki-ziemniaki-opiekane.webp",
"title": "Ziemniaki opiekane",
"description": "",
"price": "11,00"
},
{
"position": "58",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/frytki.webp",
"title": "Frytki",
"description": "",
"price": "12,00"
},
{
"position": "380",
"categoryId": "",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Kopytka",
"description": "",
"price": "11,00"
},
{
"position": "61",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/bukiet.webp",
"title": "Bukiet surówek",
"description": "",
"price": "12,00"
},
{
"position": "279",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/salatka-wiosenna.webp",
"title": "Sałatka wiosenna",
"description": "Pomidor, ogórek, papryka na liściu sałaty z lekkim sosem ogrodowym",
"price": "11,00"
},
{
"position": "62",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/3.webp",
"title": "Surówka z białej kapusty",
"description": "",
"price": "10,00"
},
{
"position": "67",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/buraczki.webp",
"title": "Surówka z buraczków",
"description": "",
"price": "10,00"
},
{
"position": "63",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/2.webp",
"title": "Surówka z marchwi i ananasa",
"description": "",
"price": "10,00"
},
{
"position": "65",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/kapusta-zasmazana.webp",
"title": "Kapusta zasmażana",
"description": "",
"price": "12,00"
},
{
"position": "381",
"categoryId": "",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Mizeria",
"description": "",
"price": "11,00"
},
{
"position": "66",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/ogorek-kiszony.webp",
"title": "Ogórek kiszony",
"description": "",
"price": "10,00"
},
{
"position": "68",
"categoryId": "8",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/dodatki-Chlebek-swojski-z-ziarnami.webp",
"title": "Chlebek swojski",
"description": "",
"price": "6,00"
}
]
},
{
"categoryName": "Cosik na słodko",
"items": [
{
"position": "77",
"categoryId": "10",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/szarlotka.webp",
"title": "Szarlotka na gorąco z gałką lodów",
"description": "",
"price": "26,00"
},
{
"position": "78",
"categoryId": "10",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/photo_2024-07-17_13-24-30.webp",
"title": "Sernik na musie truskawkowym",
"description": "",
"price": "24,00"
},
{
"position": "382",
"categoryId": "",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Fondant czekoladowy na ciepło podany na sosie wiśniowym z gałką lodów",
"description": "",
"price": "31,00"
},
{
"position": "327",
"categoryId": "10",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/deser-3.webp",
"title": "Deser lodowy",
"description": "3 gałki, smaki do wyboru",
"price": "20,00"
},
{
"position": "292",
"categoryId": "10",
"tag": "deser",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/deser-lodowy-dla-dzieci.webp",
"title": "Mini deser lodowy",
"description": "2 gałki, smaki do wyboru",
"price": "17,00"
}
]
},
{
"categoryName": "Napitki",
"items": [
{
"position": "81",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/photo_2024-07-17_13-24-04.webp",
"title": "Espresso",
"description": "",
"price": "9,00"
},
{
"position": "82",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/kawa-czarna.webp",
"title": "Kawa czarna",
"description": "",
"price": "10,00"
},
{
"position": "83",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/kawa-biala1.webp",
"title": "Kawa biała",
"description": "",
"price": "11,00"
},
{
"position": "84",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/napoje-kawa-cappuccino.webp",
"title": "Cappuccino",
"description": "",
"price": "14,00"
},
{
"position": "86",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/latte.webp",
"title": "Latte Machiato",
"description": "",
"price": "14,00"
},
{
"position": "90",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/herbata-czarna.webp",
"title": "Herbata czarna",
"description": "",
"price": "10,00"
},
{
"position": "202",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/herbata1.webp",
"title": "Herbata Richmont",
"description": "",
"price": "14,00"
},
{
"position": "329",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/herbata-owocowa1.webp",
"title": "Herbata owocowa Richmont",
"description": "",
"price": "14,00"
},
{
"position": "330",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/menu/400_320_crop/photo_2024-07-19_08-00-49.webp",
"title": "Herbata zielona Richmont",
"description": "",
"price": "14,00"
}
]
},
{
"categoryName": "Napitki dla wysusonyk",
"items": [
{
"position": "301",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/potrawy/400_320_crop/napoje-lemoniady.webp",
"title": "Orzeźwiająca lemoniada",
"description": "",
"price": "18,00"
},
{
"position": "302",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/modzajto-light.webp",
"title": "Modżajto light",
"description": "Drink bezalkoholowy",
"price": "20,00"
},
{
"position": "304",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/400_320_crop/sok-wyciskany.webp",
"title": "Sok wyciskany ze świeżych pomarańczy lub grejpfrutów",
"description": "",
"price": "20,00"
},
{
"position": "305",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/logo-cocacola.webp",
"title": "Coca-Cola, Coca-Cola zero, Fanta, Sprite",
"description": "",
"price": "11,00"
},
{
"position": "309",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/Kinley.webp",
"title": "Tonic Kinley",
"description": "Tonic Water",
"price": "11,00"
},
{
"position": "310",
"categoryId": "11",
"tag": "woda",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/logo-kropla.webp",
"title": "Kropla Beskidu 330ml",
"description": "gazowana, niegazowana",
"price": "9,00"
},
{
"position": "359",
"categoryId": "",
"tag": "",
"image": "/themes/karczmabiesiadanew/images/icon-menu.jpg",
"title": "Dzbanek wody gazowana/niegazowana",
"description": "",
"price": "19,00"
},
{
"position": "307",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/logo-fuzetea.webp",
"title": "Fuzetea",
"description": "cytrynowa z trawą cytrynową, brzoskwiniowa z hibiskusem",
"price": "10,00"
},
{
"position": "308",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/logo-cappy.webp",
"title": "Cappy",
"description": "pomarańcza, jabłko, multiwitamina, czarna porzeczka, grejpfrut",
"price": "10,00"
},
{
"position": "312",
"categoryId": "11",
"tag": "",
"image": "https://www.karczmabiesiada.eu/cache/images/files/logos/400_320_crop/logo-burn.webp",
"title": "Burn",
"description": "napój energetyczny",
"price": "13,00"
}
]
}
]

54
public/staff/auth.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'karczma2026!';
function startAdminSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function isAdminLoggedIn(): bool
{
startAdminSession();
return !empty($_SESSION['staff_logged_in']) && $_SESSION['staff_logged_in'] === true;
}
function requireAdminAuth(bool $redirectToLogin = true): void
{
if (isAdminLoggedIn()) {
return;
}
if ($redirectToLogin) {
header('Location: login.php');
exit;
}
}
function attemptAdminLogin(string $username, string $password): bool
{
startAdminSession();
if (hash_equals(ADMIN_USERNAME, $username) && hash_equals(ADMIN_PASSWORD, $password)) {
$_SESSION['staff_logged_in'] = true;
$_SESSION['staff_username'] = ADMIN_USERNAME;
return true;
}
return false;
}
function adminLogout(): void
{
startAdminSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
}

View File

@@ -1,50 +1,124 @@
<?php
require_once __DIR__ . '/auth.php';
requireAdminAuth(true);
require_once __DIR__ . '/../../config/database.php';
$tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa";
$stmt = sqlsrv_query($conn, $tsql);
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$baseUrl = "http://$host/karczma-stoliki/public/stolik2_api.html?h=";
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/public/staff'));
$publicDir = str_replace('\\', '/', dirname($scriptDir));
$basePath = rtrim($publicDir, '/') . '/app.html?h=';
$baseUrl = "{$scheme}://{$host}{$basePath}";
$previewQuery = 'preview=staff';
echo "<!DOCTYPE html>
<html lang='pl'>
function appendPreviewParam(string $link, string $previewQuery): string
{
return str_contains($link, '?') ? $link . '&' . $previewQuery : $link . '?' . $previewQuery;
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset='UTF-8'>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Generator Linków QR - Stoliki</title>
<style>
body { font-family: sans-serif; padding: 20px; }
table { border-collapse: collapse; width: 100%; max-width: 800px; }
th, td { border: 1px solid #ccc; padding: 10px; text-align: left; }
th { background: #eee; }
a { color: #0066cc; text-decoration: none; }
body { font-family: Inter, Arial, sans-serif; padding: 20px; background: #0f172a; color: #e2e8f0; }
h1 { margin: 0 0 10px; }
p { color: #94a3b8; }
table { border-collapse: collapse; width: 100%; max-width: 1100px; background: #111827; border: 1px solid #334155; }
th, td { border: 1px solid #334155; padding: 10px; text-align: left; }
th { background: #0b1220; }
a { color: #93c5fd; text-decoration: none; }
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%; }
.close { float: right; font-size: 24px; font-weight: bold; cursor: pointer; line-height: 20px; color:#cbd5e1; }
#qrcode { margin-top: 20px; display: flex; justify-content: center; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
</head>
<body>
<h1>Linki do aplikacji (Kody QR)</h1>
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach.</p>
<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. Podgląd admina nie trafia do analityki.</p>
<table>
<tr>
<th>Nazwa stolika</th>
<th>Hash (ID z bazy)</th>
<th>Bezpieczny Link (KOD QR)</th>
</tr>";
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$id = strtoupper($row['ID']);
$nazwa = htmlspecialchars($row['Nazwa']);
<th>Bezpieczny link</th>
<th>Akcje</th>
</tr>
<?php while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)): ?>
<?php
$id = strtoupper((string)$row['ID']);
$nazwa = htmlspecialchars((string)$row['Nazwa'], ENT_QUOTES, 'UTF-8');
$link = $baseUrl . $id;
echo "<tr>
<td><strong>$nazwa</strong></td>
<td style='font-size: 0.8em; color: #666;'>$id</td>
<td><a href='$link' target='_blank'>$link</a></td>
</tr>";
$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 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>
<div id="qrModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeQR()">&times;</span>
<h2 id="qrTitle">Kod QR</h2>
<div id="qrcode"></div>
<p style="margin-top:20px;"><a id="qrLink" href="" target="_blank">Otwórz link w nowym oknie</a></p>
</div>
</div>
<script>
function openQR(link, nazwa) {
document.getElementById("qrModal").style.display = "flex";
document.getElementById("qrTitle").innerText = "Stolik: " + nazwa;
document.getElementById("qrLink").href = link;
const qrContainer = document.getElementById("qrcode");
qrContainer.innerHTML = "";
new QRCode(qrContainer, {
text: link,
width: 250,
height: 250,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
}
echo " </table>
</body>
</html>";
function closeQR() {
document.getElementById("qrModal").style.display = "none";
}
window.onclick = function (event) {
const modal = document.getElementById("qrModal");
if (event.target === modal) {
closeQR();
}
};
</script>
</body>
</html>
<?php
sqlsrv_free_stmt($stmt);
sqlsrv_close($conn);

504
public/staff/index.php Normal file
View File

@@ -0,0 +1,504 @@
<?php
require_once __DIR__ . '/auth.php';
requireAdminAuth(true);
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Panel Admina</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root { --bg:#0f172a; --surface:#111827; --line:#334155; --text:#e2e8f0; --muted:#94a3b8; --accent:#3b82f6; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { width: min(1200px, 94vw); margin: 24px auto; }
.top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 16px; }
.title { font-size: 1.5rem; font-weight: 800; }
.btn { display: inline-block; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; color: var(--text); text-decoration: none; background: #0b1220; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(12,1fr); }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
.card h3 { margin: 0 0 8px; font-size: 1rem; color: #cbd5e1; }
.kpi { font-size: 1.8rem; font-weight: 800; margin-top: 4px; }
.muted { color: var(--muted); font-size: .88rem; }
.col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; } .col-6 { grid-column: span 6; } .col-8 { grid-column: span 8; } .col-12 { grid-column: span 12; }
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; }
.legend-list { margin: 0; padding-left: 18px; line-height: 1.6; color: #cbd5e1; }
.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>
<body>
<div class="wrap">
<div class="top">
<div>
<div class="title">Panel Admina</div>
<div class="muted">KDS, generator QR i analityka operacyjna.</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn" href="kds.php">Otwórz KDS</a>
<a class="btn" href="generator.php">Generator QR</a>
<a class="btn" href="logout.php">Wyloguj</a>
</div>
</div>
<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>
<button class="chip" data-days="this_year">Ten rok</button>
<button class="chip" data-days="last_year">Poprzedni rok</button>
<button class="chip" data-days="all">Cały dostępny</button>
</div>
</div>
<div class="grid">
<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">Wpisy w kolejce gościa (KDS)</div></div>
<div class="card col-8">
<h3>Top stoliki</h3>
<table>
<thead><tr><th>Stolik</th><th>Skany</th><th>Sesje</th><th>Geo pass</th><th>Geo fail</th></tr></thead>
<tbody id="topTablesBody"><tr><td colspan="5" class="muted">Ładowanie...</td></tr></tbody>
</table>
</div>
<div class="card col-4">
<h3>Strefy (Karczma/Taras)</h3>
<table>
<thead><tr><th>Strefa</th><th>Skany</th><th>Sesje</th></tr></thead>
<tbody id="zonesBody"><tr><td colspan="3" class="muted">Ładowanie...</td></tr></tbody>
</table>
</div>
<div class="card col-6">
<h3>Lejek użycia (droga użytkownika)</h3>
<table>
<tbody id="funnelBody"></tbody>
</table>
</div>
<div class="card col-6">
<h3>Geolokalizacja</h3>
<table>
<tbody id="geoBody"></tbody>
</table>
</div>
<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>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>
<div class="card col-12">
<h3>Urządzenia (skanowania QR)</h3>
<div class="muted" style="margin-bottom:12px;">Udział iPhone (iOS), Android i pozostałych w wybranym okresie.</div>
<div class="chart-wrap">
<canvas id="devicePieChart" height="260"></canvas>
</div>
<div id="deviceChartLegend" class="chart-legend"></div>
<div id="deviceChartEmpty" class="muted" style="text-align:center; display:none; margin-top:8px;">Brak skanowań w tym okresie.</div>
</div>
<div class="card col-12">
<h3>Kolejka akcji gościa (historia + statusy)</h3>
<div class="muted" style="margin-bottom:10px;">
Historia jest zachowywana. Rekordów nie usuwamy, pracujemy na statusach `api_sent` i `status_kds`.
</div>
<table style="margin-bottom:10px;">
<thead><tr><th>Wszystkie akcje</th><th>Do wysyłki API</th><th>Oczekuje na KDS</th><th>Oznaczone jako gotowe</th></tr></thead>
<tbody id="guestQueueSummaryBody"><tr><td colspan="4" class="muted">Ładowanie...</td></tr></tbody>
</table>
<table>
<thead><tr><th>ID</th><th>Kiedy</th><th>Stolik</th><th>Kelner</th><th>Typ</th><th>Treść</th><th>Wysłane API</th><th>KDS gotowe</th></tr></thead>
<tbody id="guestQueueBody"><tr><td colspan="8" class="muted">Ładowanie...</td></tr></tbody>
</table>
</div>
<div class="card col-12">
<h3>Jak czytać te dane?</h3>
<ul class="legend-list">
<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 (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> - ✓ 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>
</div>
<script>
const chips = Array.from(document.querySelectorAll(".chip"));
let selectedDays = "7";
let isLoadingAnalytics = false;
let devicePieChart = null;
let selectedVisitorSessionId = null;
const deviceChartLabels = {
ios: "iPhone (iOS)",
android: "Android",
other: "Inne",
};
const deviceChartColors = {
ios: "#60a5fa",
android: "#34d399",
other: "#94a3b8",
};
function renderDevicePieChart(stats) {
const canvas = document.getElementById("devicePieChart");
const legendEl = document.getElementById("deviceChartLegend");
const emptyEl = document.getElementById("deviceChartEmpty");
if (!canvas || typeof Chart === "undefined") return;
const order = ["ios", "android", "other"];
const values = order.map(k => Number(stats?.[k] || 0));
const total = values.reduce((a, b) => a + b, 0);
if (devicePieChart) {
devicePieChart.destroy();
devicePieChart = null;
}
if (total === 0) {
canvas.style.display = "none";
legendEl.innerHTML = "";
emptyEl.style.display = "block";
return;
}
canvas.style.display = "block";
emptyEl.style.display = "none";
devicePieChart = new Chart(canvas, {
type: "pie",
data: {
labels: order.map(k => deviceChartLabels[k]),
datasets: [{
data: values,
backgroundColor: order.map(k => deviceChartColors[k]),
borderColor: "#111827",
borderWidth: 2,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(ctx) {
const v = ctx.parsed || 0;
const pct = total ? ((v / total) * 100).toFixed(1) : "0";
return ` ${ctx.label}: ${v.toLocaleString("pl-PL")} (${pct}%)`;
},
},
},
},
},
});
legendEl.innerHTML = order.map((k, i) => {
const v = values[i];
const pct = total ? ((v / total) * 100).toFixed(1) : "0";
return `<span class="chart-legend-item"><span class="chart-legend-dot" style="background:${deviceChartColors[k]}"></span>${deviceChartLabels[k]}: ${n(v)} (${pct}%)</span>`;
}).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)));
}
function n(value) {
return Number(value || 0).toLocaleString("pl-PL");
}
async function loadAnalytics(days) {
if (isLoadingAnalytics) return;
isLoadingAnalytics = true;
try {
const res = await fetch(`../../api/analytics_reports.php?days=${days}`, { credentials: "same-origin" });
const data = await res.json();
if (data.status !== "success") throw new Error("API error");
const totalScans = (data.topTables || []).reduce((a, r) => a + Number(r.qr_scans || 0), 0);
const totalSessions = (data.topTables || []).reduce((a, r) => a + Number(r.sessions || 0), 0);
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.guestQueueSummary?.waiterCalls);
const topBody = document.getElementById("topTablesBody");
const top = data.topTables || [];
topBody.innerHTML = top.length
? top.map(r => `<tr><td>${r.table_id ?? "-"}</td><td>${n(r.qr_scans)}</td><td>${n(r.sessions)}</td><td>${n(r.geo_pass)}</td><td>${n(r.geo_fail)}</td></tr>`).join("")
: `<tr><td colspan="5" class="muted">Brak danych</td></tr>`;
const zonesBody = document.getElementById("zonesBody");
const zones = data.zoneStats || [];
zonesBody.innerHTML = zones.length
? zones.map(r => `<tr><td>${r.zone ?? "unknown"}</td><td>${n(r.qr_scans)}</td><td>${n(r.sessions)}</td></tr>`).join("")
: `<tr><td colspan="3" class="muted">Brak danych</td></tr>`;
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 (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>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>
`;
const g = data.geolocation || {};
document.getElementById("geoBody").innerHTML = `
<tr><td>Lokalizacja potwierdzona</td><td>${n(g.passed)}</td></tr>
<tr><td>Lokalizacja odrzucona / błąd</td><td>${n(g.failed)}</td></tr>
<tr><td>Wejście z hosta bypass</td><td>${n(g.bypass)}</td></tr>
`;
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 => {
const dt = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
const when = dt && !Number.isNaN(dt.getTime()) ? dt.toLocaleString("pl-PL") : (r.created_at || "-");
const device = r.device_type || "-";
const browser = r.browser || "-";
const reachedApp = Number(r.reached_app) === 1;
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 || {};
document.getElementById("guestQueueSummaryBody").innerHTML = `
<tr>
<td>${n(queueSummary.total)}</td>
<td>${n(queueSummary.pendingApi)}</td>
<td>${n(queueSummary.pendingKds)}</td>
<td>${n(queueSummary.doneKds)}</td>
</tr>
`;
const queueRows = data.guestQueue || [];
const queueBody = document.getElementById("guestQueueBody");
queueBody.innerHTML = queueRows.length
? queueRows.map(row => {
const dt = row.created_at ? new Date(String(row.created_at).replace(" ", "T")) : null;
const when = dt && !Number.isNaN(dt.getTime()) ? dt.toLocaleString("pl-PL") : (row.created_at || "-");
const typeLabel = row.message_type === "waiter_call" ? "Przywołanie kelnera" : "Prośba o rachunek";
const apiSent = Number(row.api_sent) === 1 ? "Tak" : "Nie";
const kdsDone = Number(row.status_kds) === 1 ? "Tak" : "Nie";
const messageText = String(row.message_text || "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const kelner = [row.otwierajacy_imie, row.otwierajacy_nazwisko]
.map(v => String(v || "").trim())
.filter(Boolean)
.join(" ") || "-";
return `<tr><td>${row.id}</td><td>${when}</td><td>${row.table_id || "-"}</td><td>${kelner}</td><td>${typeLabel}</td><td style="white-space:pre-line;max-width:360px">${messageText}</td><td>${apiSent}</td><td>${kdsDone}</td></tr>`;
}).join("")
: `<tr><td colspan="8" class="muted">Brak danych</td></tr>`;
} catch (e) {
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>`;
} finally {
isLoadingAnalytics = false;
}
}
chips.forEach(chip => {
chip.addEventListener("click", () => {
selectedDays = chip.dataset.days;
setChip(selectedDays);
loadAnalytics(selectedDays);
});
});
setChip(selectedDays);
loadAnalytics(selectedDays);
setInterval(() => loadAnalytics(selectedDays), 60000);
</script>
</body>
</html>

View File

@@ -1,3 +1,7 @@
<?php
require_once __DIR__ . '/auth.php';
requireAdminAuth(true);
?>
<!DOCTYPE html>
<html lang="pl">
<head>
@@ -131,11 +135,22 @@
color: var(--accent);
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 15px;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.order-operator {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 15px;
}
.order-operator strong {
color: #cbd5e1;
font-weight: 600;
}
.order-items {
display: flex;
flex-direction: column;
@@ -222,6 +237,69 @@
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.guest-alerts {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.guest-alert {
border-radius: 14px;
padding: 16px 18px;
border: 1px solid #334155;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
animation: slideIn 0.3s ease-out;
}
.guest-alert-waiter {
border-left: 5px solid var(--danger);
box-shadow: 0 0 24px rgba(239, 68, 68, 0.15);
}
.guest-alert-bill {
border-left: 5px solid var(--warning);
}
.guest-alert-title {
font-size: 1.05rem;
font-weight: 800;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.guest-alert-waiter .guest-alert-title {
color: #fca5a5;
}
.guest-alert-bill .guest-alert-title {
color: #fcd34d;
}
.guest-alert-operator {
font-size: 0.95rem;
color: var(--text-muted);
margin-bottom: 8px;
}
.guest-alert-operator strong {
color: #e2e8f0;
}
.guest-alert-msg {
font-size: 0.9rem;
color: #cbd5e1;
line-height: 1.4;
white-space: pre-line;
}
.guest-alert-time {
margin-top: 8px;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
</head>
<body>
@@ -233,6 +311,12 @@
<span id="last-sync">Łączenie z bazą...</span>
</div>
</header>
<div style="margin-bottom: 16px; display:flex; gap:8px; flex-wrap:wrap;">
<a href="index.php" style="color:#93c5fd; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px;">Wróć do panelu admina</a>
<a href="logout.php" style="color:#cbd5e1; text-decoration:none; border:1px solid #334155; padding:8px 12px; border-radius:10px;">Wyloguj</a>
</div>
<div id="guest-alerts" class="guest-alerts"></div>
<div id="kds-grid" class="kds-grid">
<div id="loading">
@@ -246,6 +330,74 @@
const lastSyncEl = document.getElementById('last-sync');
const dotEl = document.getElementById('connection-dot');
let previousDataString = "";
let previousAlertsString = "";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function formatOtwierajacy(item) {
const imie = (item.OtwierajacyImie || "").trim();
const nazwisko = (item.OtwierajacyNazwisko || "").trim();
const pelne = `${imie} ${nazwisko}`.trim();
if (pelne) return pelne;
return (item.OtwierajacyNick || "").trim();
}
function formatQueueOperator(row) {
const imie = (row.otwierajacy_imie || "").trim();
const nazwisko = (row.otwierajacy_nazwisko || "").trim();
return `${imie} ${nazwisko}`.trim();
}
async function fetchGuestAlerts() {
try {
const response = await fetch("../../api/guest_action_queue.php?kds_secret=karczma_kuchnia");
const result = await response.json();
if (result.status !== "success") return;
const alertsString = JSON.stringify(result.data || []);
if (alertsString !== previousAlertsString) {
renderGuestAlerts(result.data || []);
previousAlertsString = alertsString;
}
} catch {
// best effort
}
}
function renderGuestAlerts(items) {
const container = document.getElementById("guest-alerts");
if (!items.length) {
container.innerHTML = "";
return;
}
container.innerHTML = items.map(row => {
const isWaiter = row.message_type === "waiter_call";
const typeLabel = isWaiter ? "Wezwanie kelnera" : "Prośba o rachunek";
const operator = formatQueueOperator(row);
const operatorHtml = operator
? `<div class="guest-alert-operator">Kelner stolika: <strong>${escapeHtml(operator)}</strong></div>`
: "";
const dt = row.created_at ? new Date(String(row.created_at).replace(" ", "T")) : null;
const when = dt && !Number.isNaN(dt.getTime())
? dt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: "";
return `
<div class="guest-alert ${isWaiter ? "guest-alert-waiter" : "guest-alert-bill"}">
<div class="guest-alert-title">${typeLabel} · STOLIK ${escapeHtml(row.table_id || "?")}</div>
${operatorHtml}
<div class="guest-alert-msg">${escapeHtml(row.message_text || "")}</div>
<div class="guest-alert-time">${when}</div>
</div>
`;
}).join("");
}
async function fetchOrders() {
try {
@@ -293,7 +445,9 @@
orders[orderId] = {
number: orderId,
stolik: item.NazwaStolika || item.StolikID,
stolikId: item.StolikID,
time: item.DataDodania,
otwierajacy: formatOtwierajacy(item),
groups: {}
};
}
@@ -327,7 +481,11 @@
sortedOrders.forEach(order => {
const timeStr = new Date(order.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const stolikText = order.stolik ? `STOLIK: ${order.stolik}` : 'BRAK STOLIKA';
const stolikLink = order.stolikId ? `<a href="../app.html?h=${order.stolikId}" target="_blank" style="color: var(--accent); text-decoration: underline;">STOLIK: ${order.stolik}</a>` : `STOLIK: ${order.stolik}`;
const stolikText = order.stolik ? stolikLink : 'BRAK STOLIKA';
const operatorHtml = order.otwierajacy
? `<div class="order-operator">Kelner: <strong>${order.otwierajacy}</strong></div>`
: '';
let itemsHtml = '';
@@ -365,6 +523,7 @@
<div class="order-time">${timeStr}</div>
</div>
<div class="order-table">${stolikText}</div>
${operatorHtml}
<div class="order-items">
${itemsHtml}
</div>
@@ -374,11 +533,11 @@
});
}
// Pierwsze pobranie
fetchOrders();
fetchGuestAlerts();
// Pętla odświeżania co 3 sekundy
setInterval(fetchOrders, 3000);
setInterval(fetchGuestAlerts, 3000);
</script>
</body>
</html>

55
public/staff/login.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/auth.php';
startAdminSession();
if (isAdminLoggedIn()) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (attemptAdminLogin($username, $password)) {
header('Location: index.php');
exit;
}
$error = 'Nieprawidłowy login lub hasło.';
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Panel Admina - Logowanie</title>
<style>
body { font-family: Inter, Arial, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; display: grid; place-items: center; }
.card { width: min(420px, 92vw); background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 24px; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
p { margin: 0 0 20px; color: #94a3b8; }
label { display: block; margin-bottom: 6px; font-size: .9rem; color: #cbd5e1; }
input { width: 100%; box-sizing: border-box; border: 1px solid #475569; background: #0b1220; color: #e2e8f0; border-radius: 10px; padding: 11px 12px; margin-bottom: 14px; }
button { width: 100%; border: 0; border-radius: 10px; padding: 11px 12px; background: #3b82f6; color: #fff; font-weight: 600; cursor: pointer; }
.error { margin-bottom: 12px; padding: 10px; border-radius: 9px; background: #451a1a; color: #fecaca; border: 1px solid #7f1d1d; }
.hint { margin-top: 14px; font-size: .82rem; color: #64748b; }
</style>
</head>
<body>
<form class="card" method="post" action="">
<h1>Panel Admina</h1>
<p>Zaloguj się, aby przejść do KDS, generatora QR i analityki.</p>
<?php if ($error !== ''): ?>
<div class="error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<label for="username">Login</label>
<input id="username" name="username" required autocomplete="username">
<label for="password">Hasło</label>
<input id="password" name="password" type="password" required autocomplete="current-password">
<button type="submit">Zaloguj</button>
<div class="hint">Dane logowania są ustawione w pliku `public/staff/auth.php`.</div>
</form>
</body>
</html>

6
public/staff/logout.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/auth.php';
adminLogout();
header('Location: login.php');
exit;

9
public/waiter/.htaccess Normal file
View File

@@ -0,0 +1,9 @@
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "^(index\.php|manifest\.webmanifest|sw\.js)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>

300
public/waiter/app.css Normal file
View File

@@ -0,0 +1,300 @@
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #3b82f6;
--danger: #ef4444;
--warning: #f59e0b;
--success: #22c55e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 16px;
padding-bottom: max(24px, env(safe-area-inset-bottom));
}
.hidden {
display: none !important;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.top-bar h1 {
font-size: 1.45rem;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 4px;
font-size: 0.85rem;
color: var(--muted);
}
.sync-pill {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
white-space: nowrap;
flex-shrink: 0;
}
.sync-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.sync-dot.ok {
background: var(--success);
box-shadow: 0 0 8px var(--success);
animation: pulse 2s infinite;
}
.sync-dot.err {
background: var(--danger);
box-shadow: 0 0 8px var(--danger);
}
@keyframes pulse {
0%, 100% { opacity: 0.75; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1.1); }
}
.notify-banner,
.install-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1e3a5f 0%, #1e293b 100%);
border: 1px solid #2563eb;
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 16px;
}
.install-banner {
background: linear-gradient(135deg, #14532d 0%, #1e293b 100%);
border-color: #22c55e;
}
.notify-banner p,
.install-banner p {
margin-top: 4px;
font-size: 0.82rem;
color: #cbd5e1;
line-height: 1.4;
}
.notify-banner {
.btn {
border: none;
border-radius: 10px;
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 800;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
}
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--muted);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
.feed-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
animation: slideIn 0.25s ease-out;
}
.feed-card.is-new {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
}
.feed-card.waiter {
border-left: 5px solid var(--danger);
}
.feed-card.bill {
border-left: 5px solid var(--warning);
}
.feed-card.done {
opacity: 0.72;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.feed-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
.feed-title {
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.feed-card.waiter .feed-title {
color: #fca5a5;
}
.feed-card.bill .feed-title {
color: #fcd34d;
}
.feed-time {
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
}
.feed-table {
font-size: 1.05rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 6px;
}
.feed-operator {
font-size: 0.88rem;
color: var(--muted);
margin-bottom: 8px;
}
.feed-operator strong {
color: #e2e8f0;
}
.feed-msg {
font-size: 0.9rem;
color: #cbd5e1;
line-height: 1.45;
white-space: pre-line;
}
.feed-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--border);
}
.badge-pending {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.35);
}
.badge-done {
background: rgba(34, 197, 94, 0.12);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
@media (min-width: 640px) {
body {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}

294
public/waiter/app.js Normal file
View File

@@ -0,0 +1,294 @@
const API_URL = '../../api/waiter_feed.php';
const POLL_MS = 15000;
const feedList = document.getElementById('feedList');
const emptyState = document.getElementById('emptyState');
const syncDot = document.getElementById('syncDot');
const syncLabel = document.getElementById('syncLabel');
const notifyBanner = document.getElementById('notifyBanner');
const enableNotifyBtn = document.getElementById('enableNotifyBtn');
const installBanner = document.getElementById('installBanner');
const installAppBtn = document.getElementById('installAppBtn');
const statPending = document.getElementById('statPending');
const statWaiter = document.getElementById('statWaiter');
const statBill = document.getElementById('statBill');
const statTotal = document.getElementById('statTotal');
let knownIds = new Set();
let feedInitialized = false;
let pollTimer = null;
let lastPayload = '';
let swRegistration = null;
let deferredInstallPrompt = null;
function isStandaloneDisplay() {
return window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
}
function updateInstallBanner() {
if (!installBanner) {
return;
}
if (isStandaloneDisplay() || !deferredInstallPrompt) {
installBanner.classList.add('hidden');
return;
}
installBanner.classList.remove('hidden');
}
function setupInstallPrompt() {
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredInstallPrompt = event;
updateInstallBanner();
});
window.addEventListener('appinstalled', () => {
deferredInstallPrompt = null;
updateInstallBanner();
});
}
async function promptInstallApp() {
if (!deferredInstallPrompt) {
return;
}
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
updateInstallBanner();
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function formatTime(isoLike) {
if (!isoLike) return '';
const dt = new Date(String(isoLike).replace(' ', 'T'));
if (Number.isNaN(dt.getTime())) return '';
return dt.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
}
function formatOperator(row) {
const imie = (row.otwierajacy_imie || '').trim();
const nazwisko = (row.otwierajacy_nazwisko || '').trim();
return `${imie} ${nazwisko}`.trim();
}
function updateNotifyBanner() {
if (!('Notification' in window)) {
notifyBanner.classList.add('hidden');
return;
}
if (Notification.permission === 'default') {
notifyBanner.classList.remove('hidden');
return;
}
notifyBanner.classList.add('hidden');
}
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
return null;
}
try {
swRegistration = await navigator.serviceWorker.register('sw.js');
await navigator.serviceWorker.ready;
return swRegistration;
} catch (err) {
console.warn('[waiter] SW registration failed', err);
return null;
}
}
async function requestNotifications() {
if (!('Notification' in window)) {
alert('Ta przeglądarka nie obsługuje powiadomień.');
return;
}
const permission = await Notification.requestPermission();
updateNotifyBanner();
if (permission === 'granted') {
await registerServiceWorker();
}
}
function showNotification(row) {
const isWaiter = row.message_type === 'waiter_call';
const title = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
const firstLine = (row.message_text || '').split('\n')[0].trim();
const body = `Stolik ${row.table_id || '?'}` + (firstLine ? `\n${firstLine}` : '');
const payload = {
type: 'notify',
title,
body,
tag: `waiter-${row.id}`,
};
if (swRegistration && swRegistration.active) {
swRegistration.active.postMessage(payload);
return;
}
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(payload);
return;
}
if (Notification.permission === 'granted') {
new Notification(title, {
body,
tag: payload.tag,
renotify: true,
});
}
}
function handleNewRows(rows) {
const fresh = [];
for (const row of rows) {
const id = Number(row.id);
if (!feedInitialized) {
knownIds.add(id);
continue;
}
if (!knownIds.has(id)) {
knownIds.add(id);
fresh.push(row);
}
}
feedInitialized = true;
for (const row of fresh) {
showNotification(row);
}
}
function renderFeed(rows) {
if (!rows.length) {
feedList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
feedList.innerHTML = rows.map((row) => {
const isWaiter = row.message_type === 'waiter_call';
const typeLabel = isWaiter ? 'Wezwanie kelnera' : 'Prośba o rachunek';
const operator = formatOperator(row);
const operatorHtml = operator
? `<div class="feed-operator">Kelner stolika: <strong>${escapeHtml(operator)}</strong></div>`
: '';
const pending = Number(row.status_kds) === 0;
const cardClass = [
'feed-card',
isWaiter ? 'waiter' : 'bill',
pending ? '' : 'done',
].filter(Boolean).join(' ');
return `
<article class="${cardClass}" data-id="${row.id}">
<div class="feed-head">
<div class="feed-title">${escapeHtml(typeLabel)}</div>
<div class="feed-time">${escapeHtml(formatTime(row.created_at))}</div>
</div>
<div class="feed-table">Stolik ${escapeHtml(row.table_id || '?')}</div>
${operatorHtml}
<div class="feed-msg">${escapeHtml(row.message_text || '')}</div>
<div class="feed-badges">
<span class="badge ${pending ? 'badge-pending' : 'badge-done'}">
${pending ? 'Aktywne w KDS' : 'Obsłużone w KDS'}
</span>
</div>
</article>
`;
}).join('');
}
function updateStats(summary, total) {
statPending.textContent = String(summary?.pending ?? 0);
statWaiter.textContent = String(summary?.waiter_calls ?? 0);
statBill.textContent = String(summary?.bill_requests ?? 0);
statTotal.textContent = String(total ?? 0);
}
function setSyncState(ok, message) {
syncDot.classList.toggle('ok', ok);
syncDot.classList.toggle('err', !ok);
syncLabel.textContent = message;
}
async function pollFeed() {
try {
const response = await fetch(API_URL, { cache: 'no-store' });
const result = await response.json();
if (result.status !== 'success') {
setSyncState(false, 'Błąd API');
return;
}
const payload = JSON.stringify(result.data || []);
const rows = result.data || [];
handleNewRows(rows);
if (payload !== lastPayload) {
renderFeed(rows);
lastPayload = payload;
}
updateStats(result.summary, result.count);
const now = new Date();
setSyncState(true, `Sync ${now.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`);
} catch {
setSyncState(false, 'Brak połączenia');
}
}
function startPolling() {
pollFeed();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollFeed, POLL_MS);
}
enableNotifyBtn?.addEventListener('click', () => {
requestNotifications();
});
installAppBtn?.addEventListener('click', () => {
promptInstallApp();
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollFeed();
}
});
(async function init() {
setupInstallPrompt();
updateInstallBanner();
updateNotifyBanner();
await registerServiceWorker();
startPolling();
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

79
public/waiter/index.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/../includes/asset_version.php';
$waiterDir = __DIR__;
$vCss = publicAssetVersion($waiterDir, 'app.css');
$vJs = publicAssetVersion($waiterDir, 'app.js');
?><!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f172a">
<meta name="description" content="Wezwania kelnera i prośby o rachunek panel Biesiada">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Kelner">
<title>Kelner wezwania</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="app.css?v=<?= assetVersionAttr($vCss) ?>">
</head>
<body>
<header class="top-bar">
<div>
<h1>Panel kelnera</h1>
<p class="subtitle">Wezwania i prośby o rachunek · dziś</p>
</div>
<div class="sync-pill" id="syncPill">
<span class="sync-dot" id="syncDot"></span>
<span id="syncLabel">Łączenie…</span>
</div>
</header>
<section class="install-banner hidden" id="installBanner">
<div>
<strong>Zainstaluj aplikację</strong>
<p>Dodaj panel kelnera na ekran główny telefonu — szybszy dostęp bez paska adresu.</p>
</div>
<button type="button" class="btn btn-primary" id="installAppBtn">Instaluj</button>
</section>
<section class="notify-banner hidden" id="notifyBanner">
<div>
<strong>Powiadomienia wyłączone</strong>
<p>Włącz je, aby dostać alert przy nowym wezwaniu, nawet gdy ekran jest zablokowany w tle.</p>
</div>
<button type="button" class="btn btn-primary" id="enableNotifyBtn">Włącz powiadomienia</button>
</section>
<section class="stats-grid" id="statsGrid">
<div class="stat-card">
<span class="stat-label">Aktywne</span>
<span class="stat-value" id="statPending"></span>
</div>
<div class="stat-card">
<span class="stat-label">Kelner</span>
<span class="stat-value" id="statWaiter"></span>
</div>
<div class="stat-card">
<span class="stat-label">Rachunek</span>
<span class="stat-value" id="statBill"></span>
</div>
<div class="stat-card">
<span class="stat-label">Razem dziś</span>
<span class="stat-value" id="statTotal"></span>
</div>
</section>
<main id="feedList" class="feed-list"></main>
<div class="empty-state hidden" id="emptyState">
<div class="empty-icon">🛎️</div>
<p>Brak wezwań na dziś. Czekam na nowe…</p>
</div>
<script src="app.js?v=<?= assetVersionAttr($vJs) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
{
"id": "/app/public/waiter/",
"name": "Panel kelnera Biesiada",
"short_name": "Kelner",
"description": "Wezwania kelnera i prośby o rachunek",
"start_url": "./index.php",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#0f172a",
"background_color": "#0f172a",
"lang": "pl",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

46
public/waiter/sw.js Normal file
View File

@@ -0,0 +1,46 @@
const START_URL = './index.php';
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
for (const client of list) {
if ('focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(START_URL);
}
return undefined;
})
);
});
self.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.type !== 'notify') {
return;
}
event.waitUntil(
self.registration.showNotification(data.title || 'Panel kelnera', {
body: data.body || '',
tag: data.tag || 'waiter-alert',
renotify: true,
requireInteraction: true,
})
);
});

View File

@@ -0,0 +1,64 @@
<?php
// Daily aggregation from analytics_events to analytics_daily_table_stats
// Usage:
// php scripts/analytics_aggregate_daily.php
// php scripts/analytics_aggregate_daily.php 2026-05-28
require_once __DIR__ . '/../config/database.php';
$targetDate = $argv[1] ?? date('Y-m-d', strtotime('-1 day'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $targetDate)) {
fwrite(STDERR, "Invalid date format. Use YYYY-MM-DD\n");
exit(1);
}
try {
$pdo = getAnalyticsPdo();
$pdo->beginTransaction();
$deleteStmt = $pdo->prepare("DELETE FROM analytics_daily_table_stats WHERE stat_date = :stat_date");
$deleteStmt->execute([':stat_date' => $targetDate]);
$insertSql = "
INSERT INTO analytics_daily_table_stats (
stat_date,
table_id,
zone,
qr_scans,
sessions,
unique_devices,
geo_pass,
geo_fail,
bill_opened,
bill_completed
)
SELECT
DATE(created_at) AS stat_date,
COALESCE(NULLIF(table_id, ''), 'unknown') AS table_id,
COALESCE(NULLIF(zone, ''), 'unknown') AS zone,
SUM(CASE WHEN event_name = 'qr_scan' THEN 1 ELSE 0 END) AS qr_scans,
SUM(CASE WHEN event_name = 'session_start' THEN 1 ELSE 0 END) AS sessions,
COUNT(DISTINCT ip_hash) AS unique_devices,
SUM(CASE WHEN event_name = 'geo_check_passed' THEN 1 ELSE 0 END) AS geo_pass,
SUM(CASE WHEN event_name = 'geo_check_failed' THEN 1 ELSE 0 END) AS geo_fail,
SUM(CASE WHEN event_name = 'bill_dialog_opened' THEN 1 ELSE 0 END) AS bill_opened,
SUM(CASE WHEN event_name = 'bill_request_sent' THEN 1 ELSE 0 END) AS bill_completed
FROM analytics_events
WHERE DATE(created_at) = :stat_date
GROUP BY DATE(created_at), COALESCE(NULLIF(table_id, ''), 'unknown'), COALESCE(NULLIF(zone, ''), 'unknown')
";
$insertStmt = $pdo->prepare($insertSql);
$insertStmt->execute([':stat_date' => $targetDate]);
$pdo->commit();
echo "OK: Aggregation completed for {$targetDate}\n";
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "Aggregation failed: {$e->getMessage()}\n");
exit(1);
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* Jednorazowy generator ikon PWA dla panelu kelnera.
* Uruchom: php scripts/generate_waiter_pwa_icons.php
*/
$outDir = __DIR__ . '/../public/waiter/icons';
if (!is_dir($outDir) && !mkdir($outDir, 0755, true) && !is_dir($outDir)) {
fwrite(STDERR, "Nie można utworzyć katalogu: {$outDir}\n");
exit(1);
}
function hexToRgb(string $hex): array
{
$hex = ltrim($hex, '#');
return [
(int) hexdec(substr($hex, 0, 2)),
(int) hexdec(substr($hex, 2, 2)),
(int) hexdec(substr($hex, 4, 2)),
];
}
function drawBell($img, int $size, float $scale, int $offsetY = 0): void
{
$cx = (int) ($size / 2);
$cy = (int) ($size / 2) + $offsetY;
$gold = imagecolorallocate($img, 245, 158, 11);
$goldLight = imagecolorallocate($img, 251, 191, 36);
$white = imagecolorallocate($img, 248, 250, 252);
$bellW = (int) ($size * 0.34 * $scale);
$bellH = (int) ($size * 0.36 * $scale);
$topY = $cy - (int) ($bellH * 0.55);
$bottomY = $cy + (int) ($bellH * 0.45);
imagefilledellipse($img, $cx, $topY + (int) ($bellH * 0.15), (int) ($bellW * 0.35), (int) ($bellH * 0.18), $gold);
imagefilledellipse($img, $cx, $topY + (int) ($bellH * 0.42), $bellW, $bellH, $goldLight);
imagefilledellipse($img, $cx, $bottomY, (int) ($bellW * 1.05), (int) ($bellH * 0.22), $gold);
imagefilledellipse($img, $cx, $bottomY + (int) ($bellH * 0.18), (int) ($bellW * 0.18), (int) ($bellH * 0.14), $white);
}
function renderIcon(int $size, bool $maskable): void
{
global $outDir;
$img = imagecreatetruecolor($size, $size);
imagesavealpha($img, true);
[$bgR, $bgG, $bgB] = hexToRgb('#0f172a');
$bg = imagecolorallocatealpha($img, $bgR, $bgG, $bgB, 0);
imagefill($img, 0, 0, $bg);
$scale = $maskable ? 0.62 : 0.78;
drawBell($img, $size, $scale);
$name = $maskable ? 'icon-maskable-512.png' : ($size === 192 ? 'icon-192.png' : 'icon-512.png');
$path = $outDir . DIRECTORY_SEPARATOR . $name;
imagepng($img, $path);
imagedestroy($img);
echo "Zapisano: {$path}\n";
}
renderIcon(192, false);
renderIcon(512, false);
renderIcon(512, true);

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS guest_action_queue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
table_id VARCHAR(32) NOT NULL,
message_type ENUM('waiter_call', 'bill_request') NOT NULL,
message_text TEXT NOT NULL,
message_format ENUM('plain', 'html') NOT NULL DEFAULT 'plain' COMMENT 'deprecated — tylko plain; kolumna do usunięcia',
otwierajacy_imie VARCHAR(100) NULL,
otwierajacy_nazwisko VARCHAR(100) NULL,
api_sent TINYINT(1) NOT NULL DEFAULT 0,
status_kds TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_guest_action_api_sent_created (api_sent, created_at),
KEY idx_guest_action_status_kds_created (status_kds, created_at),
KEY idx_guest_action_table_created (table_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,2 @@
ALTER TABLE guest_action_queue
ADD COLUMN message_format ENUM('plain', 'html') NOT NULL DEFAULT 'plain' AFTER message_text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE guest_action_queue
ADD COLUMN otwierajacy_imie VARCHAR(100) NULL AFTER message_text,
ADD COLUMN otwierajacy_nazwisko VARCHAR(100) NULL AFTER otwierajacy_imie;

View File

@@ -0,0 +1,14 @@
<?php
require_once __DIR__ . '/../config/database.php';
$pdo = getAnalyticsPdo();
$columns = $pdo->query("SHOW COLUMNS FROM guest_action_queue LIKE 'message_format'")->fetch();
if ($columns) {
echo "Kolumna message_format już istnieje.\n";
exit(0);
}
$sql = file_get_contents(__DIR__ . '/guest_action_queue_add_message_format.sql');
$pdo->exec($sql);
echo "Dodano kolumnę message_format.\n";

View File

@@ -0,0 +1,14 @@
<?php
require_once __DIR__ . '/../config/database.php';
$pdo = getAnalyticsPdo();
$columns = $pdo->query("SHOW COLUMNS FROM guest_action_queue LIKE 'otwierajacy_imie'")->fetch();
if ($columns) {
echo "Kolumny otwierajacy_* już istnieją.\n";
exit(0);
}
$sql = file_get_contents(__DIR__ . '/guest_action_queue_add_operator.sql');
$pdo->exec($sql);
echo "Dodano kolumny otwierajacy_imie i otwierajacy_nazwisko.\n";

View File

@@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/../config/database.php';
$pdo = getAnalyticsPdo();
try {
$pdo->exec('DROP TABLE IF EXISTS kds_orders');
echo "Usunięto tabelę kds_orders (jeśli istniała).\n";
} catch (Throwable $e) {
echo "Uwaga: nie udało się usunąć kds_orders: {$e->getMessage()}\n";
}
$pdo->exec("DELETE FROM guest_action_queue WHERE message_text LIKE '[DEMO]%'");
$stmt = $pdo->prepare("
INSERT INTO guest_action_queue (
table_id,
message_type,
message_text,
otwierajacy_imie,
otwierajacy_nazwisko,
api_sent,
status_kds,
created_at
) VALUES (
:table_id,
:message_type,
:message_text,
:otwierajacy_imie,
:otwierajacy_nazwisko,
0,
0,
NOW(3)
)
");
$samples = [
[
'table_id' => '12',
'message_type' => 'waiter_call',
'message_text' => "[DEMO] Przywołanie kelnera",
'otwierajacy_imie' => 'Jan',
'otwierajacy_nazwisko' => 'Kowalski',
],
[
'table_id' => 'taras 5',
'message_type' => 'bill_request',
'message_text' => "[DEMO] Prośba o rachunek\nForma płatności: karta\nDokument: paragon",
'otwierajacy_imie' => 'Anna',
'otwierajacy_nazwisko' => 'Nowak',
],
];
foreach ($samples as $sample) {
$stmt->execute($sample);
echo "Wstawiono demo: {$sample['message_type']} / stolik {$sample['table_id']}\n";
}
echo "KDS feed: GET api/guest_action_queue.php?kds_secret=karczma_kuchnia\n";

26
verify.js Normal file
View File

@@ -0,0 +1,26 @@
const fs = require('fs');
const txt = fs.readFileSync('menu.txt', 'utf8');
const json = JSON.parse(fs.readFileSync('public/menu.json', 'utf8'));
const txtItemCount = (txt.match(/<div class="rmc-position"/g) || []).length;
const txtCategoryCount = (txt.match(/<div class="rm-category">/g) || []).length - 1; // -1 because the first split in the previous script had an empty category if there was a preceding tag? Actually, let's just count `class="restaurant-menu-category"`
const txtCatNameCount = (txt.match(/class="restaurant-menu-category">/g) || []).length;
let jsonItemCount = 0;
json.forEach(cat => {
jsonItemCount += cat.items.length;
});
console.log(`Menu.txt item count: ${txtItemCount}`);
console.log(`JSON item count: ${jsonItemCount}`);
console.log(`Menu.txt category count: ${txtCatNameCount}`);
console.log(`JSON category count: ${json.length}`);
if (txtItemCount === jsonItemCount && txtCatNameCount === json.length) {
console.log("MATCH: Wszystkie pozycje i kategorie zostały prawidłowo przeniesione.");
} else {
console.log("MISMATCH: Występują braki!");
}