Aplikacja kelnera jako PWA

This commit is contained in:
2026-06-20 15:11:49 +02:00
parent 99bb83702a
commit 83ea4184b4
10 changed files with 210 additions and 9 deletions

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>

View File

@@ -87,7 +87,8 @@ body {
50% { opacity: 1; transform: scale(1.1); }
}
.notify-banner {
.notify-banner,
.install-banner {
display: flex;
align-items: center;
justify-content: space-between;
@@ -99,13 +100,21 @@ body {
margin-bottom: 16px;
}
.notify-banner p {
.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;

View File

@@ -7,6 +7,8 @@ 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');
@@ -18,6 +20,49 @@ 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 ?? '')
@@ -230,6 +275,10 @@ enableNotifyBtn?.addEventListener('click', () => {
requestNotifications();
});
installAppBtn?.addEventListener('click', () => {
promptInstallApp();
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollFeed();
@@ -237,13 +286,9 @@ document.addEventListener('visibilitychange', () => {
});
(async function init() {
setupInstallPrompt();
updateInstallBanner();
updateNotifyBanner();
if ('Notification' in window && Notification.permission === 'granted') {
await registerServiceWorker();
} else if ('Notification' in window && Notification.permission === 'default') {
notifyBanner.classList.remove('hidden');
}
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

View File

@@ -9,9 +9,14 @@ $vJs = publicAssetVersion($waiterDir, 'app.js');
<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>
@@ -27,6 +32,14 @@ $vJs = publicAssetVersion($waiterDir, 'app.js');
</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>

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"
}
]
}

View File

@@ -1,3 +1,5 @@
const START_URL = './index.php';
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
@@ -6,6 +8,27 @@ 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') {

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);