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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user