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`. 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). 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ń? ## 💡 Jak działa mapowanie zamówień?
- Aplikacja KDS wyciąga dane łącząc tabele `dbo.NGastroDTRachunek` i `dbo.NGastroDTRachunekPozycja`. - 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.ID,
r.Numer, r.Numer,
r.Opis, 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 FROM dbo.NGastroDTRachunek r
LEFT JOIN dbo.NGastroStolik s ON s.ID = r.StolikID 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) WHERE CAST(r.DataOtwarcia as DATE) = CAST(GETDATE() as DATE)
AND r.Status = 0 AND r.Status = 0
"; ";
@@ -63,12 +68,22 @@ while ($row = sqlsrv_fetch_array($stmtBills, SQLSRV_FETCH_ASSOC)) {
if ($isMatched) { if ($isMatched) {
$billId = $row['ID']; $billId = $row['ID'];
$matchedBillIds[] = $billId; $matchedBillIds[] = $billId;
$imie = trim((string) ($row['OtwierajacyImie'] ?? ''));
$nazwisko = trim((string) ($row['OtwierajacyNazwisko'] ?? ''));
$nick = trim((string) ($row['OtwierajacyNick'] ?? ''));
$pelneImie = trim($imie . ' ' . $nazwisko);
$bills[$billId] = [ $bills[$billId] = [
'id' => $billId, 'id' => $billId,
'numer' => $row['Numer'], 'numer' => $row['Numer'],
'opis' => $row['Opis'], 'opis' => $row['Opis'],
'suma' => 0, '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.Numer AS NumerRachunku,
r.Opis, r.Opis,
r.NumerReczny AS NumerRecznyRachunku, r.NumerReczny AS NumerRecznyRachunku,
o.Imie AS OtwierajacyImie,
o.Nazwisko AS OtwierajacyNazwisko,
o.Nick AS OtwierajacyNick,
rp.ID AS PozycjaID, rp.ID AS PozycjaID,
rp.StatusRealizacji, rp.StatusRealizacji,
rp.Ilosc, rp.Ilosc,
@@ -51,6 +54,8 @@ $tsql = "
LEFT JOIN dbo.NGastroTowar t ON t.ID = rp.TowarID LEFT JOIN dbo.NGastroTowar t ON t.ID = rp.TowarID
LEFT JOIN dbo.NGastroTowar tz ON tz.ID = rp.ZestawID LEFT JOIN dbo.NGastroTowar tz ON tz.ID = rp.ZestawID
LEFT JOIN dbo.NGastroStolik s ON s.ID = r.StolikID 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) LEFT JOIN dbo.NGastroKonfiguracjaDrukowaniaZamowien kdz ON kdz.ID = ISNULL(rp.KonfiguracjaDrukowaniaZamowienID, t.KonfiguracjaDrukowaniaZamowienID)
WHERE r.Status = 0 WHERE r.Status = 0
AND rp.StatusRealizacji > 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() 'errors' => sqlsrv_errors()
], JSON_UNESCAPED_UNICODE)); ], 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> <p>Szybki dostęp do modułów aplikacji <strong>Karczma-Stoliki</strong>.</p>
<div class="links-grid"> <div class="links-grid">
<a href="public/staff/generator.php" class="dev-link"> <a href="public/staff/index.php" class="dev-link">
🔗 Generator kodów QR (Kelner) 🔐 Panel Admina (logowanie)
</a> </a>
<a href="public/staff/kds.php" class="dev-link kds"> <a href="public/staff/kds.php" class="dev-link kds">
🍳 Ekran KDS (Kuchnia) 🍳 Ekran KDS (Kuchnia)
</a> </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) 📱 Aplikacja dla Gościa (Poprosi o Hash)
</a> </a>
</div> </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,17 +1,31 @@
<!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"> <html lang="pl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Karczma Biesiada Twoje Zamówienie</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link <link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap"
rel="stylesheet"> rel="stylesheet">
<link rel="stylesheet" href="assets/css/stolik3_api.css"> <link rel="stylesheet" href="assets/css/app.css?v=<?= assetVersionAttr($vCss) ?>">
</head> </head>
<body> <body>
@@ -24,13 +38,39 @@
</div> </div>
<div id="geoScreen" class="hidden"> <div id="geoScreen" class="hidden">
<div class="geo-icon">📍</div> <div class="geo-shell">
<div class="geo-text"> <div class="geo-icon">📍</div>
<h2>Prywatność i Lokalizacja</h2> <h2 class="geo-title">Witamy w Karcznie</h2>
<div class="geo-msg" id="geoMsg"> <p class="geo-lead" id="geoLead">
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. 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> </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>
</div> </div>
@@ -39,9 +79,9 @@
<h1 class="logo-text">Karczma Biesiada</h1> <h1 class="logo-text">Karczma Biesiada</h1>
<div id="tableLabel" class="table-badge">Wybierz stolik</div> <div id="tableLabel" class="table-badge">Wybierz stolik</div>
</header> </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;"> 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"> <main id="mainContent">
<div id="statusView" class="view-section active"> <div id="statusView" class="view-section active">
@@ -91,6 +131,10 @@
</div> <!-- Koniec statusView --> </div> <!-- Koniec statusView -->
<div id="menuView" class="view-section hidden"> <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"> <div class="menu-search-container">
<input type="text" id="menuSearchInput" placeholder="Szukaj dania..." oninput="filterMenu()" /> <input type="text" id="menuSearchInput" placeholder="Szukaj dania..." oninput="filterMenu()" />
</div> </div>
@@ -98,82 +142,23 @@
<div class="restaurant-menu-container"> <div class="restaurant-menu-container">
<nav class="menu-categories-nav"> <nav class="menu-categories-nav">
<ul> <ul>
<li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0)">Wszystko</a></li> <li><a href="#" class="active" data-category-badge="0" onclick="showCategory(0)">Wszystko</a></li>
<li><a href="#" onclick="showCategory(1)" data-category-badge="1">Przystawki</a></li> <li><a href="#" onclick="showCategory(1)" data-category-badge="1">Przystawki</a></li>
<li><a href="#" onclick="showCategory(2)" data-category-badge="2">Zupy</a></li> <li><a href="#" onclick="showCategory(2)" data-category-badge="2">Zupy</a></li>
<li><a href="#" onclick="showCategory(3)" data-category-badge="3">Dania główne</a></li> <li><a href="#" onclick="showCategory(3)" data-category-badge="3">Dania główne</a></li>
<li><a href="#" onclick="showCategory(4)" data-category-badge="4">Dania swojskie</a></li> <li><a href="#" onclick="showCategory(4)" data-category-badge="4">Dania swojskie</a></li>
<li><a href="#" onclick="showCategory(5)" data-category-badge="5">Ryby</a></li> <li><a href="#" onclick="showCategory(5)" data-category-badge="5">Ryby</a></li>
<li><a href="#" onclick="showCategory(7)" data-category-badge="7">Sałatki</a></li> <li><a href="#" onclick="showCategory(7)" data-category-badge="7">Sałatki</a></li>
<li><a href="#" onclick="showCategory(6)" data-category-badge="6">Makarony</a></li> <li><a href="#" onclick="showCategory(6)" data-category-badge="6">Makarony</a></li>
<li><a href="#" onclick="showCategory(9)" data-category-badge="9">Dla dzieci</a></li> <li><a href="#" onclick="showCategory(9)" data-category-badge="9">Dla dzieci</a></li>
<li><a href="#" onclick="showCategory(8)" data-category-badge="8">Dodatki</a></li> <li><a href="#" onclick="showCategory(8)" data-category-badge="8">Dodatki</a></li>
<li><a href="#" onclick="showCategory(10)" data-category-badge="10">Desery</a></li> <li><a href="#" onclick="showCategory(10)" data-category-badge="10">Desery</a></li>
<li><a href="#" onclick="showCategory(11)" data-category-badge="11">Napoje</a></li> <li><a href="#" onclick="showCategory(11)" data-category-badge="11">Napoje</a></li>
</ul> </ul>
</nav> </nav>
<div class="restaurant-menu-scroll"> <div class="restaurant-menu-scroll" id="menuContainer">
<div class="rm-category" data-cat-id="1"> <!-- Dynamiczne menu załaduje się tutaj -->
<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> </div>
</div> </div>
</div> <!-- Koniec menuView --> </div> <!-- Koniec menuView -->
@@ -198,11 +183,11 @@
<span class="nav-icon">📖</span> <span class="nav-icon">📖</span>
<span class="nav-label">Menu</span> <span class="nav-label">Menu</span>
</div> </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-icon">🛎️</span>
<span class="nav-label">Kelner</span> <span class="nav-label">Kelner</span>
</div> </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-icon">💳</span>
<span class="nav-label">Rachunek</span> <span class="nav-label">Rachunek</span>
</div> </div>
@@ -226,7 +211,7 @@
</div> </div>
</div> </div>
<!-- NAME DIALOG --> <!-- NAME DIALOG (Tymczasowo wyłączone)
<div class="modal-overlay" id="nameModal"> <div class="modal-overlay" id="nameModal">
<div class="modal-content" style="text-align: center;"> <div class="modal-content" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 15px;">👋</div> <div style="font-size: 48px; margin-bottom: 15px;">👋</div>
@@ -245,6 +230,35 @@
</div> </div>
</div> </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 --> <!-- BILL DIALOG -->
<div class="modal-overlay" id="billModal"> <div class="modal-overlay" id="billModal">
@@ -280,7 +294,7 @@
<div style="display:flex; gap:12px;"> <div style="display:flex; gap:12px;">
<button class="btn btn-secondary" style="flex:1;" onclick="goBackToBillList()" <button class="btn btn-secondary" style="flex:1;" onclick="goBackToBillList()"
id="btnBackToBills">Wróć</button> 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>
</div> </div>
@@ -366,7 +380,11 @@
<span style="font-size:20px;"></span> <span id="toastText">Wysłano!</span> <span style="font-size:20px;"></span> <span id="toastText">Wysłano!</span>
</div> </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> </body>
</html> </html>

View File

@@ -71,33 +71,191 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 40px; padding: 24px 20px;
text-align: center; text-align: center;
transition: opacity 0.5s ease, visibility 0.5s; transition: opacity 0.5s ease, visibility 0.5s;
overflow-y: auto;
}
.geo-shell {
width: 100%;
max-width: 400px;
} }
.geo-icon { .geo-icon {
font-size: 80px; font-size: 64px;
margin-bottom: 16px; margin-bottom: 12px;
animation: bounce 2s infinite; animation: bounce 2s infinite;
} }
@keyframes bounce { @keyframes bounce {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); } 50% { transform: translateY(-10px); }
} }
.geo-text h2 { .geo-title {
font-family: 'Playfair Display', serif; 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); 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); 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; 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 --- */ /* --- MAIN LAYOUT --- */
@@ -557,11 +715,13 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
pointer-events: none;
} }
.toast.active { .toast.active {
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
opacity: 1; opacity: 1;
pointer-events: auto;
} }
/* --- KITCHEN ANIMATION --- */ /* --- KITCHEN ANIMATION --- */
@@ -869,6 +1029,58 @@ header {
filter: grayscale(0) opacity(1); 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 { .nav-item:active .nav-icon {
transform: scale(0.9); transform: scale(0.9);
} }
@@ -894,6 +1106,10 @@ header {
margin-bottom: 10px; margin-bottom: 10px;
} }
#menuOnlyBanner.is-hidden + .menu-search-container {
padding-top: 0;
}
#menuSearchInput { #menuSearchInput {
width: 100%; width: 100%;
background: var(--surface); background: var(--surface);
@@ -1040,4 +1256,158 @@ header {
content: " zł"; content: " zł";
font-size: 12px; font-size: 12px;
font-weight: 400; 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 <?php
require_once __DIR__ . '/auth.php';
requireAdminAuth(true);
require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../../config/database.php';
$tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa"; $tsql = "SELECT ID, Nazwa FROM dbo.NGastroStolik ORDER BY Nazwa";
$stmt = sqlsrv_query($conn, $tsql); $stmt = sqlsrv_query($conn, $tsql);
$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $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> function appendPreviewParam(string $link, string $previewQuery): string
<html lang='pl'> {
return str_contains($link, '?') ? $link . '&' . $previewQuery : $link . '?' . $previewQuery;
}
?>
<!doctype html>
<html lang="pl">
<head> <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> <title>Generator Linków QR - Stoliki</title>
<style> <style>
body { font-family: sans-serif; padding: 20px; } body { font-family: Inter, Arial, sans-serif; padding: 20px; background: #0f172a; color: #e2e8f0; }
table { border-collapse: collapse; width: 100%; max-width: 800px; } h1 { margin: 0 0 10px; }
th, td { border: 1px solid #ccc; padding: 10px; text-align: left; } p { color: #94a3b8; }
th { background: #eee; } table { border-collapse: collapse; width: 100%; max-width: 1100px; background: #111827; border: 1px solid #334155; }
a { color: #0066cc; text-decoration: none; } 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; } 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> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
</head> </head>
<body> <body>
<h1>Linki do aplikacji (Kody QR)</h1> <a class="btn-nav" href="index.php">Wróć do panelu admina</a>
<p>Skopiuj poniższe linki lub wygeneruj z nich kody QR do umieszczenia na stolikach.</p> <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> <table>
<tr> <tr>
<th>Nazwa stolika</th> <th>Nazwa stolika</th>
<th>Hash (ID z bazy)</th> <th>Hash (ID z bazy)</th>
<th>Bezpieczny Link (KOD QR)</th> <th>Bezpieczny link</th>
</tr>"; <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;
$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>
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) { <div id="qrModal" class="modal">
$id = strtoupper($row['ID']); <div class="modal-content">
$nazwa = htmlspecialchars($row['Nazwa']); <span class="close" onclick="closeQR()">&times;</span>
$link = $baseUrl . $id; <h2 id="qrTitle">Kod QR</h2>
echo "<tr> <div id="qrcode"></div>
<td><strong>$nazwa</strong></td> <p style="margin-top:20px;"><a id="qrLink" href="" target="_blank">Otwórz link w nowym oknie</a></p>
<td style='font-size: 0.8em; color: #666;'>$id</td> </div>
<td><a href='$link' target='_blank'>$link</a></td> </div>
</tr>";
}
echo " </table> <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
});
}
function closeQR() {
document.getElementById("qrModal").style.display = "none";
}
window.onclick = function (event) {
const modal = document.getElementById("qrModal");
if (event.target === modal) {
closeQR();
}
};
</script>
</body> </body>
</html>"; </html>
<?php
sqlsrv_free_stmt($stmt); sqlsrv_free_stmt($stmt);
sqlsrv_close($conn); 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> <!DOCTYPE html>
<html lang="pl"> <html lang="pl">
<head> <head>
@@ -131,11 +135,22 @@
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 15px; margin-bottom: 8px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; 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 { .order-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -222,6 +237,69 @@
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 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> </style>
</head> </head>
<body> <body>
@@ -233,6 +311,12 @@
<span id="last-sync">Łączenie z bazą...</span> <span id="last-sync">Łączenie z bazą...</span>
</div> </div>
</header> </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="kds-grid" class="kds-grid">
<div id="loading"> <div id="loading">
@@ -246,6 +330,74 @@
const lastSyncEl = document.getElementById('last-sync'); const lastSyncEl = document.getElementById('last-sync');
const dotEl = document.getElementById('connection-dot'); const dotEl = document.getElementById('connection-dot');
let previousDataString = ""; 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() { async function fetchOrders() {
try { try {
@@ -293,7 +445,9 @@
orders[orderId] = { orders[orderId] = {
number: orderId, number: orderId,
stolik: item.NazwaStolika || item.StolikID, stolik: item.NazwaStolika || item.StolikID,
stolikId: item.StolikID,
time: item.DataDodania, time: item.DataDodania,
otwierajacy: formatOtwierajacy(item),
groups: {} groups: {}
}; };
} }
@@ -327,7 +481,11 @@
sortedOrders.forEach(order => { sortedOrders.forEach(order => {
const timeStr = new Date(order.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 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 = ''; let itemsHtml = '';
@@ -365,6 +523,7 @@
<div class="order-time">${timeStr}</div> <div class="order-time">${timeStr}</div>
</div> </div>
<div class="order-table">${stolikText}</div> <div class="order-table">${stolikText}</div>
${operatorHtml}
<div class="order-items"> <div class="order-items">
${itemsHtml} ${itemsHtml}
</div> </div>
@@ -374,11 +533,11 @@
}); });
} }
// Pierwsze pobranie
fetchOrders(); fetchOrders();
fetchGuestAlerts();
// Pętla odświeżania co 3 sekundy
setInterval(fetchOrders, 3000); setInterval(fetchOrders, 3000);
setInterval(fetchGuestAlerts, 3000);
</script> </script>
</body> </body>
</html> </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!");
}