@djangocfg/layouts 2.1.35 → 2.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +5 -5
  2. package/src/layouts/AppLayout/BaseApp.tsx +31 -25
  3. package/src/layouts/shared/types.ts +36 -0
  4. package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
  5. package/src/snippets/PWA/@docs/research.md +576 -0
  6. package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
  7. package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
  8. package/src/snippets/PWA/@refactoring/README.md +204 -0
  9. package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
  10. package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
  11. package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
  12. package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
  13. package/src/snippets/PWA/@refactoring2/README.md +85 -0
  14. package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
  15. package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
  16. package/src/snippets/PWA/README.md +387 -0
  17. package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
  18. package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
  19. package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
  20. package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
  21. package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
  22. package/src/snippets/PWA/config.ts +20 -0
  23. package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
  24. package/src/snippets/PWA/context/InstallContext.tsx +118 -0
  25. package/src/snippets/PWA/context/PushContext.tsx +156 -0
  26. package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
  27. package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
  28. package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
  29. package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
  30. package/src/snippets/PWA/index.ts +95 -0
  31. package/src/snippets/PWA/types/components.ts +101 -0
  32. package/src/snippets/PWA/types/index.ts +26 -0
  33. package/src/snippets/PWA/types/install.ts +38 -0
  34. package/src/snippets/PWA/types/platform.ts +29 -0
  35. package/src/snippets/PWA/types/push.ts +21 -0
  36. package/src/snippets/PWA/utils/localStorage.ts +203 -0
  37. package/src/snippets/PWA/utils/logger.ts +149 -0
  38. package/src/snippets/PWA/utils/platform.ts +151 -0
  39. package/src/snippets/PWA/utils/vapid.ts +226 -0
  40. package/src/snippets/index.ts +30 -0
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
4
+ import { usePushNotifications } from '../hooks/usePushNotifications';
5
+ import { pwaLogger } from '../utils/logger';
6
+ import type { PushNotificationOptions } from '../types';
7
+
8
+ const ICON_URL = 'https://djangocfg.com/static/logos/192x192.png';
9
+
10
+ export interface PushMessage {
11
+ id: string;
12
+ title: string;
13
+ body: string;
14
+ icon?: string;
15
+ badge?: string;
16
+ tag?: string;
17
+ timestamp: number;
18
+ data?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface PushContextValue {
22
+ // Push state
23
+ isSupported: boolean;
24
+ permission: NotificationPermission;
25
+ isSubscribed: boolean;
26
+ subscription: PushSubscription | null;
27
+
28
+ // Accumulated pushes
29
+ pushes: PushMessage[];
30
+
31
+ // Actions
32
+ subscribe: () => Promise<boolean>;
33
+ unsubscribe: () => Promise<boolean>;
34
+ sendPush: (message: Omit<PushMessage, 'id' | 'timestamp'>) => Promise<void>;
35
+ clearPushes: () => void;
36
+ removePush: (id: string) => void;
37
+ }
38
+
39
+ const PushContext = createContext<PushContextValue | undefined>(undefined);
40
+
41
+ interface PushProviderProps {
42
+ children: React.ReactNode;
43
+ vapidPublicKey?: string;
44
+ subscribeEndpoint?: string;
45
+ sendEndpoint?: string;
46
+ onPushReceived?: (push: PushMessage) => void;
47
+ }
48
+
49
+ export function PushProvider({
50
+ children,
51
+ vapidPublicKey = '',
52
+ subscribeEndpoint = '/api/push/subscribe',
53
+ sendEndpoint = '/api/push/send',
54
+ onPushReceived,
55
+ }: PushProviderProps) {
56
+ const pushNotifications = usePushNotifications({
57
+ vapidPublicKey,
58
+ subscribeEndpoint,
59
+ });
60
+
61
+ const [pushes, setPushes] = useState<PushMessage[]>([]);
62
+
63
+ // Listen for push messages from service worker
64
+ useEffect(() => {
65
+ if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
66
+ return;
67
+ }
68
+
69
+ const handleMessage = (event: MessageEvent) => {
70
+ if (event.data && event.data.type === 'PUSH_RECEIVED') {
71
+ const push: PushMessage = {
72
+ id: crypto.randomUUID(),
73
+ timestamp: Date.now(),
74
+ ...event.data.notification,
75
+ };
76
+
77
+ setPushes(prev => [push, ...prev]);
78
+ onPushReceived?.(push);
79
+ }
80
+ };
81
+
82
+ navigator.serviceWorker.addEventListener('message', handleMessage);
83
+
84
+ return () => {
85
+ navigator.serviceWorker.removeEventListener('message', handleMessage);
86
+ };
87
+ }, [onPushReceived]);
88
+
89
+ const sendPush = useCallback(async (message: Omit<PushMessage, 'id' | 'timestamp'>) => {
90
+ if (!pushNotifications.isSubscribed) {
91
+ throw new Error('Not subscribed to push notifications');
92
+ }
93
+
94
+ // In real app, this calls your backend API to send actual push
95
+ try {
96
+ // Send to server
97
+ await fetch(sendEndpoint, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ subscription: pushNotifications.subscription,
102
+ notification: message,
103
+ }),
104
+ });
105
+
106
+ // Optimistic update for UI
107
+ const push: PushMessage = {
108
+ ...message,
109
+ id: crypto.randomUUID(),
110
+ timestamp: Date.now(),
111
+ };
112
+
113
+ setPushes(prev => [push, ...prev]);
114
+ } catch (error) {
115
+ pwaLogger.error('[PushContext] Failed to send push:', error);
116
+ throw error;
117
+ }
118
+ }, [pushNotifications.isSubscribed, pushNotifications.subscription, sendEndpoint]);
119
+
120
+ const clearPushes = useCallback(() => {
121
+ setPushes([]);
122
+ }, []);
123
+
124
+ const removePush = useCallback((id: string) => {
125
+ setPushes(prev => prev.filter(p => p.id !== id));
126
+ }, []);
127
+
128
+ const value: PushContextValue = {
129
+ isSupported: pushNotifications.isSupported,
130
+ permission: pushNotifications.permission,
131
+ isSubscribed: pushNotifications.isSubscribed,
132
+ subscription: pushNotifications.subscription,
133
+ pushes,
134
+ subscribe: pushNotifications.subscribe,
135
+ unsubscribe: pushNotifications.unsubscribe,
136
+ sendPush,
137
+ clearPushes,
138
+ removePush,
139
+ };
140
+
141
+ return <PushContext.Provider value={value}>{children}</PushContext.Provider>;
142
+ }
143
+
144
+ /**
145
+ * Use push notifications context
146
+ * Must be used within <PushProvider>
147
+ */
148
+ export function usePush(): PushContextValue {
149
+ const context = useContext(PushContext);
150
+
151
+ if (context === undefined) {
152
+ throw new Error('usePush must be used within <PushProvider>');
153
+ }
154
+
155
+ return context;
156
+ }
@@ -0,0 +1,277 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Django Push Notifications Hook
5
+ *
6
+ * Integrates @djangocfg/layouts/PWA with Django-CFG backend API.
7
+ * Uses generated TypeScript client from @djangocfg/api for type-safe integration.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * import { useDjangoPush } from '@djangocfg/layouts/PWA';
12
+ *
13
+ * function NotificationsButton() {
14
+ * const { subscribe, isSubscribed, permission } = useDjangoPush({
15
+ * vapidPublicKey: 'YOUR_VAPID_KEY'
16
+ * });
17
+ *
18
+ * return (
19
+ * <button onClick={subscribe} disabled={isSubscribed}>
20
+ * {isSubscribed ? 'Subscribed' : 'Enable Notifications'}
21
+ * </button>
22
+ * );
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ import { useState, useCallback } from 'react';
28
+ import { usePushNotifications } from './usePushNotifications';
29
+ import { pwaLogger } from '../utils/logger';
30
+ import type { PushNotificationOptions } from '../types';
31
+
32
+ // Import Django API client
33
+ // @ts-ignore - optional peer dependency
34
+ import { apiAccounts, apiWebPush } from '@djangocfg/api/clients';
35
+
36
+ interface UseDjangoPushOptions extends PushNotificationOptions {
37
+ /**
38
+ * Auto-subscribe on mount if permission granted
39
+ * @default false
40
+ */
41
+ autoSubscribe?: boolean;
42
+
43
+ /**
44
+ * Callback when subscription created
45
+ */
46
+ onSubscribed?: (subscription: PushSubscription) => void;
47
+
48
+ /**
49
+ * Callback when subscription failed
50
+ */
51
+ onSubscribeError?: (error: Error) => void;
52
+
53
+ /**
54
+ * Callback when unsubscribed
55
+ */
56
+ onUnsubscribed?: () => void;
57
+ }
58
+
59
+ interface UseDjangoPushReturn {
60
+ // State
61
+ isSupported: boolean;
62
+ permission: NotificationPermission;
63
+ isSubscribed: boolean;
64
+ subscription: PushSubscription | null;
65
+ isLoading: boolean;
66
+ error: Error | null;
67
+
68
+ // Actions
69
+ subscribe: () => Promise<boolean>;
70
+ unsubscribe: () => Promise<boolean>;
71
+ sendTestPush: (message: { title: string; body: string; url?: string }) => Promise<boolean>;
72
+ }
73
+
74
+ /**
75
+ * Hook for Django-CFG push notifications integration
76
+ */
77
+ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushReturn {
78
+ const {
79
+ autoSubscribe = false,
80
+ onSubscribed,
81
+ onSubscribeError,
82
+ onUnsubscribed,
83
+ ...pushOptions
84
+ } = options;
85
+
86
+ const [isLoading, setIsLoading] = useState(false);
87
+ const [error, setError] = useState<Error | null>(null);
88
+
89
+ // Use base push notifications hook
90
+ const pushNotifications = usePushNotifications(pushOptions);
91
+
92
+ /**
93
+ * Subscribe to push notifications and save to Django backend
94
+ */
95
+ const subscribe = useCallback(async (): Promise<boolean> => {
96
+ if (!pushNotifications.isSupported) {
97
+ const err = new Error('Push notifications not supported');
98
+ setError(err);
99
+ onSubscribeError?.(err);
100
+ return false;
101
+ }
102
+
103
+ if (!options.vapidPublicKey) {
104
+ const err = new Error('VAPID public key required');
105
+ setError(err);
106
+ onSubscribeError?.(err);
107
+ return false;
108
+ }
109
+
110
+ setIsLoading(true);
111
+ setError(null);
112
+
113
+ try {
114
+ // Step 1: Request permission
115
+ const permission = await Notification.requestPermission();
116
+ if (permission !== 'granted') {
117
+ throw new Error(`Permission ${permission}`);
118
+ }
119
+
120
+ // Step 2: Get service worker registration
121
+ const registration = await navigator.serviceWorker.ready;
122
+
123
+ // Step 3: Subscribe to push manager
124
+ const subscription = await registration.pushManager.subscribe({
125
+ userVisibleOnly: true,
126
+ applicationServerKey: urlBase64ToUint8Array(options.vapidPublicKey) as BufferSource,
127
+ });
128
+
129
+ pwaLogger.info('[useDjangoPush] Browser subscription created:', {
130
+ endpoint: subscription.endpoint.substring(0, 50) + '...',
131
+ });
132
+
133
+ // Step 4: Save to Django backend using generated API client
134
+ const result = await apiWebPush.web_push.webpushSubscribeCreate({
135
+ endpoint: subscription.endpoint,
136
+ keys: {
137
+ p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
138
+ auth: arrayBufferToBase64(subscription.getKey('auth')),
139
+ },
140
+ });
141
+
142
+ pwaLogger.info('[useDjangoPush] Subscription saved to Django:', result);
143
+
144
+ onSubscribed?.(subscription);
145
+ return true;
146
+ } catch (err) {
147
+ const error = err instanceof Error ? err : new Error(String(err));
148
+ pwaLogger.error('[useDjangoPush] Subscribe failed:', error);
149
+ setError(error);
150
+ onSubscribeError?.(error);
151
+ return false;
152
+ } finally {
153
+ setIsLoading(false);
154
+ }
155
+ }, [
156
+ pushNotifications.isSupported,
157
+ options.vapidPublicKey,
158
+ onSubscribed,
159
+ onSubscribeError,
160
+ ]);
161
+
162
+ /**
163
+ * Unsubscribe from push notifications
164
+ */
165
+ const unsubscribe = useCallback(async (): Promise<boolean> => {
166
+ if (!pushNotifications.subscription) {
167
+ return false;
168
+ }
169
+
170
+ setIsLoading(true);
171
+ setError(null);
172
+
173
+ try {
174
+ // Unsubscribe from push manager
175
+ await pushNotifications.subscription.unsubscribe();
176
+ pwaLogger.info('[useDjangoPush] Browser unsubscribed');
177
+
178
+ // Note: Django backend will auto-cleanup inactive subscriptions
179
+ // No need to explicitly remove since subscription.endpoint becomes invalid
180
+
181
+ onUnsubscribed?.();
182
+ return true;
183
+ } catch (err) {
184
+ const error = err instanceof Error ? err : new Error(String(err));
185
+ pwaLogger.error('[useDjangoPush] Unsubscribe failed:', error);
186
+ setError(error);
187
+ return false;
188
+ } finally {
189
+ setIsLoading(false);
190
+ }
191
+ }, [pushNotifications.subscription, onUnsubscribed]);
192
+
193
+ /**
194
+ * Send test push notification
195
+ */
196
+ const sendTestPush = useCallback(
197
+ async (message: { title: string; body: string; url?: string }): Promise<boolean> => {
198
+ if (!pushNotifications.isSubscribed) {
199
+ const err = new Error('Not subscribed');
200
+ setError(err);
201
+ return false;
202
+ }
203
+
204
+ setIsLoading(true);
205
+ setError(null);
206
+
207
+ try {
208
+ const result = await apiWebPush.web_push.webpushSendCreate({
209
+ title: message.title,
210
+ body: message.body,
211
+ url: message.url || '/',
212
+ icon: '/icon.png',
213
+ });
214
+
215
+ pwaLogger.info('[useDjangoPush] Test push sent:', result);
216
+ return result.success;
217
+ } catch (err) {
218
+ const error = err instanceof Error ? err : new Error(String(err));
219
+ pwaLogger.error('[useDjangoPush] Send test push failed:', error);
220
+ setError(error);
221
+ return false;
222
+ } finally {
223
+ setIsLoading(false);
224
+ }
225
+ },
226
+ [pushNotifications.isSubscribed]
227
+ );
228
+
229
+ return {
230
+ // State
231
+ isSupported: pushNotifications.isSupported,
232
+ permission: pushNotifications.permission,
233
+ isSubscribed: pushNotifications.isSubscribed,
234
+ subscription: pushNotifications.subscription,
235
+ isLoading,
236
+ error,
237
+
238
+ // Actions
239
+ subscribe,
240
+ unsubscribe,
241
+ sendTestPush,
242
+ };
243
+ }
244
+
245
+ // ============================================================================
246
+ // Utilities
247
+ // ============================================================================
248
+
249
+ /**
250
+ * Convert base64 VAPID key to Uint8Array
251
+ */
252
+ function urlBase64ToUint8Array(base64String: string): Uint8Array {
253
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
254
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
255
+
256
+ const rawData = window.atob(base64);
257
+ const outputArray = new Uint8Array(rawData.length);
258
+
259
+ for (let i = 0; i < rawData.length; ++i) {
260
+ outputArray[i] = rawData.charCodeAt(i);
261
+ }
262
+ return outputArray;
263
+ }
264
+
265
+ /**
266
+ * Convert ArrayBuffer to base64 string
267
+ */
268
+ function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
269
+ if (!buffer) return '';
270
+
271
+ const bytes = new Uint8Array(buffer);
272
+ let binary = '';
273
+ for (let i = 0; i < bytes.byteLength; i++) {
274
+ binary += String.fromCharCode(bytes[i]);
275
+ }
276
+ return window.btoa(binary);
277
+ }
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * PWA Install Prompt Hook
5
+ *
6
+ * Manages beforeinstallprompt event and installation state
7
+ * Uses existing hooks from @djangocfg/ui-nextjs for platform detection
8
+ */
9
+
10
+ import { useEffect, useState } from 'react';
11
+ import { useBrowserDetect, useDeviceDetect } from '@djangocfg/ui-nextjs';
12
+
13
+ import type { BeforeInstallPromptEvent, InstallPromptState, InstallOutcome } from '../types';
14
+ import { markAppInstalled } from '../utils/localStorage';
15
+ import { isStandalone, onDisplayModeChange } from '../utils/platform';
16
+ import { pwaLogger } from '../utils/logger';
17
+
18
+ export function useInstallPrompt() {
19
+ const browser = useBrowserDetect();
20
+ const device = useDeviceDetect();
21
+
22
+ const [state, setState] = useState<InstallPromptState>(() => {
23
+ if (typeof window === 'undefined') {
24
+ return {
25
+ isIOS: false,
26
+ isAndroid: false,
27
+ isSafari: false,
28
+ isChrome: false,
29
+ isInstalled: false,
30
+ canPrompt: false,
31
+ deferredPrompt: null,
32
+ };
33
+ }
34
+
35
+ // Real Safari = Safari && NOT Chromium (avoid Arc, Brave on iOS)
36
+ const isSafari = browser.isSafari && !browser.isChromium;
37
+
38
+ return {
39
+ isIOS: device.isIOS,
40
+ isAndroid: device.isAndroid,
41
+ isSafari,
42
+ isChrome: browser.isChrome || browser.isChromium,
43
+ isInstalled: isStandalone(),
44
+ canPrompt: false,
45
+ deferredPrompt: null,
46
+ };
47
+ });
48
+
49
+ // Update state when platform info changes
50
+ useEffect(() => {
51
+ const isSafari = browser.isSafari && !browser.isChromium;
52
+
53
+ setState((prev) => ({
54
+ ...prev,
55
+ isIOS: device.isIOS,
56
+ isAndroid: device.isAndroid,
57
+ isSafari,
58
+ isChrome: browser.isChrome || browser.isChromium,
59
+ isInstalled: isStandalone(),
60
+ }));
61
+ }, [browser, device]);
62
+
63
+ // Listen for beforeinstallprompt event (Android Chrome only)
64
+ useEffect(() => {
65
+ if (typeof window === 'undefined') return;
66
+
67
+ const handleBeforeInstallPrompt = (e: Event) => {
68
+ // Prevent the default browser install prompt
69
+ e.preventDefault();
70
+
71
+ const event = e as BeforeInstallPromptEvent;
72
+
73
+ setState((prev) => ({
74
+ ...prev,
75
+ canPrompt: true,
76
+ deferredPrompt: event,
77
+ }));
78
+ };
79
+
80
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
81
+
82
+ return () => {
83
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
84
+ };
85
+ }, []);
86
+
87
+ // Listen for appinstalled event (Android Chrome)
88
+ useEffect(() => {
89
+ if (typeof window === 'undefined') return;
90
+
91
+ const handleAppInstalled = () => {
92
+ setState((prev) => ({
93
+ ...prev,
94
+ canPrompt: false,
95
+ deferredPrompt: null,
96
+ isInstalled: true,
97
+ }));
98
+
99
+ // Mark as installed in localStorage
100
+ markAppInstalled();
101
+ };
102
+
103
+ window.addEventListener('appinstalled', handleAppInstalled);
104
+
105
+ return () => {
106
+ window.removeEventListener('appinstalled', handleAppInstalled);
107
+ };
108
+ }, []);
109
+
110
+ // Check for display-mode changes (user adds to home screen)
111
+ useEffect(() => {
112
+ const cleanup = onDisplayModeChange((isStandaloneMode) => {
113
+ if (isStandaloneMode) {
114
+ setState((prev) => ({
115
+ ...prev,
116
+ isInstalled: true,
117
+ canPrompt: false,
118
+ deferredPrompt: null,
119
+ }));
120
+
121
+ markAppInstalled();
122
+ }
123
+ });
124
+
125
+ return cleanup;
126
+ }, []);
127
+
128
+ /**
129
+ * Trigger Android native install prompt
130
+ */
131
+ const promptInstall = async (): Promise<InstallOutcome> => {
132
+ if (!state.deferredPrompt) {
133
+ pwaLogger.warn('[PWA Install] No deferred prompt available');
134
+ return null;
135
+ }
136
+
137
+ try {
138
+ // Show the native prompt
139
+ await state.deferredPrompt.prompt();
140
+
141
+ // Wait for user response
142
+ const { outcome } = await state.deferredPrompt.userChoice;
143
+
144
+ pwaLogger.info('[PWA Install] User choice:', outcome);
145
+
146
+ // Clear the deferred prompt
147
+ setState((prev) => ({
148
+ ...prev,
149
+ deferredPrompt: null,
150
+ canPrompt: false,
151
+ }));
152
+
153
+ return outcome;
154
+ } catch (error) {
155
+ pwaLogger.error('[PWA Install] Error showing install prompt:', error);
156
+ return null;
157
+ }
158
+ };
159
+
160
+ return {
161
+ ...state,
162
+ promptInstall,
163
+ };
164
+ }
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Hook to detect if app is running as PWA
5
+ *
6
+ * Checks if the app is running in standalone mode (added to home screen).
7
+ * Results are cached in sessionStorage for fast initialization across page loads.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * // Basic usage (standard check)
12
+ * const isPWA = useIsPWA();
13
+ *
14
+ * // Reliable check (with manifest validation for desktop)
15
+ * const isPWA = useIsPWA({ reliable: true });
16
+ * ```
17
+ */
18
+
19
+ import { useState, useEffect } from 'react';
20
+ import { isStandalone, isStandaloneReliable, onDisplayModeChange } from '../utils/platform';
21
+
22
+ const CACHE_KEY = 'pwa_is_standalone';
23
+
24
+ /**
25
+ * Options for useIsPWA hook
26
+ */
27
+ export interface UseIsPWAOptions {
28
+ /**
29
+ * Use reliable check with additional validation for desktop browsers
30
+ * This prevents false positives on Safari macOS "Add to Dock"
31
+ * @default false
32
+ */
33
+ reliable?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Hook to detect if app is running as PWA (standalone mode)
38
+ *
39
+ * @param options - Configuration options
40
+ * @returns true if app is running as PWA
41
+ */
42
+ export function useIsPWA(options?: UseIsPWAOptions): boolean {
43
+ const checkFunction = options?.reliable ? isStandaloneReliable : isStandalone;
44
+
45
+ const [isPWA, setIsPWA] = useState<boolean>(() => {
46
+ // Try to restore from cache for fast initialization
47
+ if (typeof window !== 'undefined') {
48
+ try {
49
+ const cached = sessionStorage.getItem(CACHE_KEY);
50
+ if (cached !== null) {
51
+ return cached === 'true';
52
+ }
53
+ } catch {
54
+ // Ignore sessionStorage errors (e.g., in incognito mode)
55
+ }
56
+ }
57
+
58
+ // Initial check
59
+ return checkFunction();
60
+ });
61
+
62
+ useEffect(() => {
63
+ // Check after mount (may differ from SSR)
64
+ const isStandaloneMode = checkFunction();
65
+ setIsPWA(isStandaloneMode);
66
+
67
+ // Update cache
68
+ if (typeof window !== 'undefined') {
69
+ try {
70
+ sessionStorage.setItem(CACHE_KEY, String(isStandaloneMode));
71
+ } catch {
72
+ // Ignore sessionStorage errors
73
+ }
74
+ }
75
+
76
+ // Listen for display-mode changes
77
+ const cleanup = onDisplayModeChange((newValue) => {
78
+ setIsPWA(newValue);
79
+
80
+ // Update cache
81
+ if (typeof window !== 'undefined') {
82
+ try {
83
+ sessionStorage.setItem(CACHE_KEY, String(newValue));
84
+ } catch {
85
+ // Ignore sessionStorage errors
86
+ }
87
+ }
88
+ });
89
+
90
+ return cleanup;
91
+ }, [checkFunction]);
92
+
93
+ return isPWA;
94
+ }
95
+
96
+ /**
97
+ * Clear isPWA cache
98
+ *
99
+ * Useful for testing or when you want to force a re-check
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * import { clearIsPWACache } from '@djangocfg/layouts/snippets';
104
+ * clearIsPWACache();
105
+ * window.location.reload();
106
+ * ```
107
+ */
108
+ export function clearIsPWACache(): void {
109
+ if (typeof window === 'undefined') return;
110
+ try {
111
+ sessionStorage.removeItem(CACHE_KEY);
112
+ } catch {
113
+ // Ignore sessionStorage errors
114
+ }
115
+ }