@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.
- package/package.json +5 -5
- package/src/layouts/AppLayout/BaseApp.tsx +31 -25
- package/src/layouts/shared/types.ts +36 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
- package/src/snippets/PWA/@docs/research.md +576 -0
- package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
- package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
- package/src/snippets/PWA/@refactoring/README.md +204 -0
- package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
- package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
- package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
- package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
- package/src/snippets/PWA/@refactoring2/README.md +85 -0
- package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
- package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
- package/src/snippets/PWA/README.md +387 -0
- package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
- package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
- package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
- package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
- package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
- package/src/snippets/PWA/config.ts +20 -0
- package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
- package/src/snippets/PWA/context/InstallContext.tsx +118 -0
- package/src/snippets/PWA/context/PushContext.tsx +156 -0
- package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
- package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
- package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
- package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
- package/src/snippets/PWA/index.ts +95 -0
- package/src/snippets/PWA/types/components.ts +101 -0
- package/src/snippets/PWA/types/index.ts +26 -0
- package/src/snippets/PWA/types/install.ts +38 -0
- package/src/snippets/PWA/types/platform.ts +29 -0
- package/src/snippets/PWA/types/push.ts +21 -0
- package/src/snippets/PWA/utils/localStorage.ts +203 -0
- package/src/snippets/PWA/utils/logger.ts +149 -0
- package/src/snippets/PWA/utils/platform.ts +151 -0
- package/src/snippets/PWA/utils/vapid.ts +226 -0
- 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
|
+
}
|