Aplikacja kelnera jako PWA
This commit is contained in:
9
public/waiter/.htaccess
Normal file
9
public/waiter/.htaccess
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
BIN
public/waiter/icons/icon-192.png
Normal file
BIN
public/waiter/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 999 B |
BIN
public/waiter/icons/icon-512.png
Normal file
BIN
public/waiter/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/waiter/icons/icon-maskable-512.png
Normal file
BIN
public/waiter/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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>
|
||||
|
||||
33
public/waiter/manifest.webmanifest
Normal file
33
public/waiter/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
69
scripts/generate_waiter_pwa_icons.php
Normal file
69
scripts/generate_waiter_pwa_icons.php
Normal 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);
|
||||
Reference in New Issue
Block a user