Task 5.24: PWA — vite-plugin-pwa with autoUpdate, manifest, workbox caching, PWAInstallPrompt component, notification helper, SVG icons, 6 tests

This commit is contained in:
Agent Zero
2026-07-24 00:01:53 +02:00
parent d54a87cf84
commit 96e183bab2
11 changed files with 4034 additions and 3 deletions
+3720
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -46,6 +46,7 @@
"zustand": "^4.5.5"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
@@ -61,7 +62,7 @@
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-bundle-visualizer": "^1.2.1",
"vitest": "^2.1.0",
"@playwright/test": "^1.48.0"
"vite-plugin-pwa": "^1.3.0",
"vitest": "^2.1.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="12" fill="#2563eb"/>
<text x="32" y="42" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">L</text>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="36" fill="#2563eb"/>
<text x="96" y="128" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="white" text-anchor="middle">L</text>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<text x="256" y="340" font-family="Arial, sans-serif" font-size="256" font-weight="bold" fill="white" text-anchor="middle">L</text>
</svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1,96 @@
/**
* PWA Install Prompt tests — Task 5.24.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
import { getNotificationPermission, isPWAInstalled } from '@/utils/notifications';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe('PWAInstallPrompt', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('renders nothing when no beforeinstallprompt event fires', () => {
render(<PWAInstallPrompt />);
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
});
it('shows install prompt when beforeinstallprompt fires', async () => {
render(<PWAInstallPrompt />);
const event = new Event('beforeinstallprompt');
Object.assign(event, {
prompt: vi.fn().mockResolvedValue(undefined),
userChoice: Promise.resolve({ outcome: 'accepted' }),
});
window.dispatchEvent(event);
await waitFor(() => {
expect(screen.getByTestId('pwa-install-prompt')).toBeInTheDocument();
});
expect(screen.getByTestId('pwa-install-btn')).toBeInTheDocument();
expect(screen.getByTestId('pwa-dismiss-btn')).toBeInTheDocument();
});
it('hides when dismiss button is clicked', async () => {
render(<PWAInstallPrompt />);
const event = new Event('beforeinstallprompt');
Object.assign(event, {
prompt: vi.fn().mockResolvedValue(undefined),
userChoice: Promise.resolve({ outcome: 'dismissed' }),
});
window.dispatchEvent(event);
await waitFor(() => {
expect(screen.getByTestId('pwa-install-prompt')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('pwa-dismiss-btn'));
await waitFor(() => {
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
});
// Should not show again after dismiss (localStorage)
expect(localStorage.getItem('leocrm_pwa_install_dismissed')).toBe('1');
});
it('does not show when already dismissed', () => {
localStorage.setItem('leocrm_pwa_install_dismissed', '1');
render(<PWAInstallPrompt />);
const event = new Event('beforeinstallprompt');
Object.assign(event, {
prompt: vi.fn(),
userChoice: Promise.resolve({ outcome: 'dismissed' }),
});
window.dispatchEvent(event);
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
});
});
describe('Notification helpers', () => {
it('getNotificationPermission returns unsupported when Notification API missing', () => {
const original = (window as any).Notification;
delete (window as any).Notification;
expect(getNotificationPermission()).toBe('unsupported');
(window as any).Notification = original;
});
it('isPWAInstalled returns false in browser mode', () => {
expect(isPWAInstalled()).toBe(false);
});
});
@@ -0,0 +1,89 @@
/**
* PWA Install Prompt — shows install button when PWA is installable (Task 5.24).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const DISMISS_KEY = 'leocrm_pwa_install_dismissed';
export function PWAInstallPrompt() {
const { t } = useTranslation();
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setVisible(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === 'accepted') {
setVisible(false);
}
setDeferredPrompt(null);
}, [deferredPrompt]);
const handleDismiss = useCallback(() => {
localStorage.setItem(DISMISS_KEY, '1');
setVisible(false);
}, []);
if (!visible || !deferredPrompt) return null;
return (
<div
className="fixed bottom-4 right-4 z-50 bg-white rounded-lg shadow-lg border border-secondary-200 p-4 max-w-sm"
data-testid="pwa-install-prompt"
>
<div className="flex items-start gap-3">
<Download className="w-5 h-5 text-primary-600 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-secondary-900">{t('pwa.installTitle')}</p>
<p className="text-sm text-secondary-600 mt-1">{t('pwa.installDescription')}</p>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
onClick={handleInstall}
data-testid="pwa-install-btn"
>
{t('pwa.install')}
</button>
<button
className="px-3 py-1.5 text-secondary-600 text-sm hover:bg-secondary-100 rounded-md"
onClick={handleDismiss}
data-testid="pwa-dismiss-btn"
>
{t('pwa.dismiss')}
</button>
</div>
</div>
<button
className="text-secondary-400 hover:text-secondary-600"
onClick={handleDismiss}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
+7
View File
@@ -1053,5 +1053,12 @@
"city": "Stadt",
"postalCode": "PLZ"
}
},
"pwa": {
"installTitle": "App installieren",
"installDescription": "LeoCRM als App auf Ihrem Gerät installieren für schnelleren Zugriff.",
"install": "Installieren",
"dismiss": "Später",
"installed": "LeoCRM ist installiert."
}
}
+7
View File
@@ -1053,5 +1053,12 @@
"city": "City",
"postalCode": "Postal Code"
}
},
"pwa": {
"installTitle": "Install App",
"installDescription": "Install LeoCRM as an app on your device for faster access.",
"install": "Install",
"dismiss": "Later",
"installed": "LeoCRM is installed."
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Notification permission helper — request and check notification permissions (Task 5.24).
*/
export type NotificationPermissionState = 'default' | 'granted' | 'denied' | 'unsupported';
export function getNotificationPermission(): NotificationPermissionState {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission as NotificationPermissionState;
}
export async function requestNotificationPermission(): Promise<NotificationPermissionState> {
if (!('Notification' in window)) return 'unsupported';
if (Notification.permission === 'granted') return 'granted';
if (Notification.permission === 'denied') return 'denied';
try {
const result = await Notification.requestPermission();
return result as NotificationPermissionState;
} catch {
return 'denied';
}
}
export function showNotification(title: string, options?: NotificationOptions): void {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
try {
new Notification(title, options);
} catch {
// Notification creation can fail in some browsers
}
}
export function isPWAInstalled(): boolean {
try {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
} catch {
return false;
}
}
+59 -1
View File
@@ -1,9 +1,67 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon-192.svg', 'icon-512.svg'],
manifest: {
name: 'LeoCRM',
short_name: 'LeoCRM',
description: 'Mini-CRM für kleine Unternehmen',
theme_color: '#2563eb',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any maskable',
},
{
src: 'icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
},
},
},
{
urlPattern: /\.(?:js|css|woff2?)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),