@djangocfg/layouts 2.1.37 → 2.1.38
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/README.md +204 -18
- package/package.json +5 -5
- package/src/components/errors/index.ts +9 -0
- package/src/components/errors/types.ts +38 -0
- package/src/layouts/AppLayout/AppLayout.tsx +33 -45
- package/src/layouts/AppLayout/BaseApp.tsx +104 -33
- package/src/layouts/AuthLayout/AuthContext.tsx +7 -1
- package/src/layouts/AuthLayout/OAuthProviders.tsx +1 -10
- package/src/layouts/AuthLayout/OTPForm.tsx +1 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +1 -1
- package/src/layouts/PublicLayout/PublicLayout.tsx +1 -1
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +1 -1
- package/src/layouts/_components/UserMenu.tsx +1 -1
- package/src/layouts/index.ts +1 -1
- package/src/layouts/types/index.ts +47 -0
- package/src/layouts/types/layout.types.ts +61 -0
- package/src/layouts/types/providers.types.ts +65 -0
- package/src/layouts/types/ui.types.ts +103 -0
- package/src/snippets/Analytics/index.ts +1 -0
- package/src/snippets/Analytics/types.ts +10 -0
- package/src/snippets/PWAInstall/@docs/README.md +92 -0
- package/src/snippets/PWAInstall/README.md +185 -0
- package/src/snippets/{PWA → PWAInstall}/components/A2HSHint.tsx +85 -84
- package/src/snippets/PWAInstall/components/DesktopGuide.tsx +229 -0
- package/src/snippets/PWAInstall/context/InstallContext.tsx +102 -0
- package/src/snippets/{PWA → PWAInstall}/hooks/useInstallPrompt.ts +3 -0
- package/src/snippets/{PWA → PWAInstall}/index.ts +12 -31
- package/src/snippets/{PWA → PWAInstall}/types/components.ts +0 -6
- package/src/snippets/PWAInstall/types/config.ts +22 -0
- package/src/snippets/{PWA → PWAInstall}/types/index.ts +4 -4
- package/src/snippets/{PWA → PWAInstall}/utils/localStorage.ts +1 -23
- package/src/snippets/PushNotifications/@docs/README.md +191 -0
- package/src/snippets/PushNotifications/@docs/guides/django-integration.md +648 -0
- package/src/snippets/PushNotifications/@docs/guides/service-worker.md +467 -0
- package/src/snippets/PushNotifications/@docs/guides/vapid-setup.md +352 -0
- package/src/snippets/PushNotifications/README.md +328 -0
- package/src/snippets/{PWA → PushNotifications}/config.ts +2 -2
- package/src/snippets/PushNotifications/context/DjangoPushContext.tsx +190 -0
- package/src/snippets/{PWA → PushNotifications}/hooks/useDjangoPush.ts +63 -81
- package/src/snippets/{PWA → PushNotifications}/hooks/usePushNotifications.ts +12 -8
- package/src/snippets/PushNotifications/index.ts +87 -0
- package/src/snippets/PushNotifications/types/config.ts +28 -0
- package/src/snippets/PushNotifications/types/index.ts +9 -0
- package/src/snippets/PushNotifications/utils/localStorage.ts +60 -0
- package/src/snippets/PushNotifications/utils/logger.ts +149 -0
- package/src/snippets/PushNotifications/utils/platform.ts +151 -0
- package/src/snippets/index.ts +37 -12
- package/src/layouts/shared/index.ts +0 -21
- package/src/layouts/shared/types.ts +0 -247
- package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +0 -1179
- package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +0 -271
- package/src/snippets/PWA/@refactoring/README.md +0 -204
- package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +0 -1109
- package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +0 -718
- package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +0 -188
- package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +0 -362
- package/src/snippets/PWA/@refactoring2/README.md +0 -85
- package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +0 -1321
- package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +0 -557
- package/src/snippets/PWA/README.md +0 -387
- package/src/snippets/PWA/context/DjangoPushContext.tsx +0 -105
- package/src/snippets/PWA/context/InstallContext.tsx +0 -118
- package/src/snippets/PWA/context/PushContext.tsx +0 -156
- /package/src/layouts/{shared → types}/README.md +0 -0
- /package/src/snippets/{PWA/@docs/research.md → PWAInstall/@docs/research/ios-android-install-flows.md} +0 -0
- /package/src/snippets/{PWA → PWAInstall}/components/IOSGuide.tsx +0 -0
- /package/src/snippets/{PWA → PWAInstall}/components/IOSGuideDrawer.tsx +0 -0
- /package/src/snippets/{PWA → PWAInstall}/components/IOSGuideModal.tsx +0 -0
- /package/src/snippets/{PWA → PWAInstall}/hooks/useIsPWA.ts +0 -0
- /package/src/snippets/{PWA → PWAInstall}/types/install.ts +0 -0
- /package/src/snippets/{PWA → PWAInstall}/types/platform.ts +0 -0
- /package/src/snippets/{PWA → PWAInstall}/utils/logger.ts +0 -0
- /package/src/snippets/{PWA → PWAInstall}/utils/platform.ts +0 -0
- /package/src/snippets/{PWA → PushNotifications}/components/PushPrompt.tsx +0 -0
- /package/src/snippets/{PWA → PushNotifications}/types/push.ts +0 -0
- /package/src/snippets/{PWA → PushNotifications}/utils/vapid.ts +0 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Django Push Context
|
|
5
|
+
*
|
|
6
|
+
* Provider for Django-CFG push notifications integration.
|
|
7
|
+
* Wraps useDjangoPush hook in React context for easy consumption.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { DjangoPushProvider, useDjangoPushContext } from '@djangocfg/layouts/PWA';
|
|
12
|
+
*
|
|
13
|
+
* // In layout
|
|
14
|
+
* <DjangoPushProvider vapidPublicKey={process.env.NEXT_PUBLIC_VAPID_KEY}>
|
|
15
|
+
* {children}
|
|
16
|
+
* </DjangoPushProvider>
|
|
17
|
+
*
|
|
18
|
+
* // In component
|
|
19
|
+
* function NotifyButton() {
|
|
20
|
+
* const { subscribe, isSubscribed } = useDjangoPushContext();
|
|
21
|
+
* return <button onClick={subscribe}>Subscribe</button>;
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
27
|
+
import { useDjangoPush } from '../hooks/useDjangoPush';
|
|
28
|
+
import { pwaLogger } from '../utils/logger';
|
|
29
|
+
import type { PushNotificationOptions } from '../types';
|
|
30
|
+
|
|
31
|
+
export interface PushMessage {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
body: string;
|
|
35
|
+
icon?: string;
|
|
36
|
+
badge?: string;
|
|
37
|
+
tag?: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
data?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DjangoPushContextValue {
|
|
43
|
+
// State
|
|
44
|
+
isSupported: boolean;
|
|
45
|
+
permission: NotificationPermission;
|
|
46
|
+
isSubscribed: boolean;
|
|
47
|
+
subscription: PushSubscription | null;
|
|
48
|
+
isLoading: boolean;
|
|
49
|
+
error: Error | null;
|
|
50
|
+
|
|
51
|
+
// Push history
|
|
52
|
+
pushes: PushMessage[];
|
|
53
|
+
|
|
54
|
+
// Actions
|
|
55
|
+
subscribe: () => Promise<boolean>;
|
|
56
|
+
unsubscribe: () => Promise<boolean>;
|
|
57
|
+
sendTestPush: (message: { title: string; body: string; url?: string }) => Promise<boolean>;
|
|
58
|
+
sendPush: (message: Omit<PushMessage, 'id' | 'timestamp'>) => Promise<void>;
|
|
59
|
+
clearPushes: () => void;
|
|
60
|
+
removePush: (id: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DjangoPushContext = createContext<DjangoPushContextValue | undefined>(undefined);
|
|
64
|
+
|
|
65
|
+
interface DjangoPushProviderProps extends PushNotificationOptions {
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Auto-subscribe on mount if permission granted
|
|
70
|
+
* @default false
|
|
71
|
+
*/
|
|
72
|
+
autoSubscribe?: boolean;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Callback when subscription created
|
|
76
|
+
*/
|
|
77
|
+
onSubscribed?: (subscription: PushSubscription) => void;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Callback when subscription failed
|
|
81
|
+
*/
|
|
82
|
+
onSubscribeError?: (error: Error) => void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Callback when unsubscribed
|
|
86
|
+
*/
|
|
87
|
+
onUnsubscribed?: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Provider for Django push notifications
|
|
92
|
+
*/
|
|
93
|
+
export function DjangoPushProvider({
|
|
94
|
+
children,
|
|
95
|
+
vapidPublicKey,
|
|
96
|
+
autoSubscribe = false,
|
|
97
|
+
onSubscribed,
|
|
98
|
+
onSubscribeError,
|
|
99
|
+
onUnsubscribed,
|
|
100
|
+
}: DjangoPushProviderProps) {
|
|
101
|
+
const djangoPush = useDjangoPush({
|
|
102
|
+
vapidPublicKey,
|
|
103
|
+
onSubscribed,
|
|
104
|
+
onSubscribeError,
|
|
105
|
+
onUnsubscribed,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Auto-subscribe on mount if permission already granted
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (
|
|
111
|
+
autoSubscribe &&
|
|
112
|
+
djangoPush.isSupported &&
|
|
113
|
+
djangoPush.permission === 'granted' &&
|
|
114
|
+
!djangoPush.isSubscribed &&
|
|
115
|
+
!djangoPush.isLoading
|
|
116
|
+
) {
|
|
117
|
+
pwaLogger.info('[DjangoPushProvider] Auto-subscribing (permission already granted)');
|
|
118
|
+
djangoPush.subscribe();
|
|
119
|
+
}
|
|
120
|
+
}, [autoSubscribe, djangoPush.isSupported, djangoPush.permission, djangoPush.isSubscribed, djangoPush.isLoading]);
|
|
121
|
+
|
|
122
|
+
const [pushes, setPushes] = useState<PushMessage[]>([]);
|
|
123
|
+
|
|
124
|
+
// Listen for push messages from service worker
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleMessage = (event: MessageEvent) => {
|
|
131
|
+
if (event.data && event.data.type === 'PUSH_RECEIVED') {
|
|
132
|
+
const push: PushMessage = {
|
|
133
|
+
id: crypto.randomUUID(),
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
...event.data.notification,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
setPushes(prev => [push, ...prev]);
|
|
139
|
+
pwaLogger.info('[DjangoPushProvider] Push received:', push);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
navigator.serviceWorker.addEventListener('message', handleMessage);
|
|
144
|
+
return () => navigator.serviceWorker.removeEventListener('message', handleMessage);
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const sendPush = useCallback(
|
|
148
|
+
async (message: Omit<PushMessage, 'id' | 'timestamp'>) => {
|
|
149
|
+
await djangoPush.sendTestPush({
|
|
150
|
+
title: message.title,
|
|
151
|
+
body: message.body,
|
|
152
|
+
url: message.data?.url as string | undefined,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
[djangoPush]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const clearPushes = useCallback(() => {
|
|
159
|
+
setPushes([]);
|
|
160
|
+
pwaLogger.info('[DjangoPushProvider] Push history cleared');
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const removePush = useCallback((id: string) => {
|
|
164
|
+
setPushes(prev => prev.filter(p => p.id !== id));
|
|
165
|
+
pwaLogger.info('[DjangoPushProvider] Push removed:', id);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const value: DjangoPushContextValue = {
|
|
169
|
+
...djangoPush,
|
|
170
|
+
pushes,
|
|
171
|
+
sendPush,
|
|
172
|
+
clearPushes,
|
|
173
|
+
removePush,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return <DjangoPushContext.Provider value={value}>{children}</DjangoPushContext.Provider>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Hook to access Django push context
|
|
181
|
+
*/
|
|
182
|
+
export function useDjangoPushContext(): DjangoPushContextValue {
|
|
183
|
+
const context = useContext(DjangoPushContext);
|
|
184
|
+
|
|
185
|
+
if (context === undefined) {
|
|
186
|
+
throw new Error('useDjangoPushContext must be used within DjangoPushProvider');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return context;
|
|
190
|
+
}
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Django Push Notifications Hook
|
|
5
5
|
*
|
|
6
|
-
* Integrates
|
|
6
|
+
* Integrates usePushNotifications with Django-CFG backend API.
|
|
7
7
|
* Uses generated TypeScript client from @djangocfg/api for type-safe integration.
|
|
8
|
+
* Requires authentication - opens AuthDialog if user is not logged in.
|
|
9
|
+
*
|
|
10
|
+
* Architecture:
|
|
11
|
+
* - usePushNotifications: handles all browser Push API operations
|
|
12
|
+
* - useDjangoPush: adds Django backend sync on top
|
|
8
13
|
*
|
|
9
14
|
* @example
|
|
10
15
|
* ```tsx
|
|
11
|
-
* import { useDjangoPush } from '@djangocfg/layouts/
|
|
16
|
+
* import { useDjangoPush } from '@djangocfg/layouts/snippets';
|
|
12
17
|
*
|
|
13
18
|
* function NotificationsButton() {
|
|
14
|
-
* const { subscribe,
|
|
19
|
+
* const { subscribe, unsubscribe, isSubscribed } = useDjangoPush({
|
|
15
20
|
* vapidPublicKey: 'YOUR_VAPID_KEY'
|
|
16
21
|
* });
|
|
17
22
|
*
|
|
18
23
|
* return (
|
|
19
|
-
* <button onClick={subscribe}
|
|
20
|
-
* {isSubscribed ? '
|
|
24
|
+
* <button onClick={isSubscribed ? unsubscribe : subscribe}>
|
|
25
|
+
* {isSubscribed ? 'Unsubscribe' : 'Subscribe'}
|
|
21
26
|
* </button>
|
|
22
27
|
* );
|
|
23
28
|
* }
|
|
@@ -25,21 +30,20 @@
|
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
import { useState, useCallback } from 'react';
|
|
33
|
+
import { toast } from 'sonner';
|
|
28
34
|
import { usePushNotifications } from './usePushNotifications';
|
|
29
35
|
import { pwaLogger } from '../utils/logger';
|
|
30
36
|
import type { PushNotificationOptions } from '../types';
|
|
31
37
|
|
|
38
|
+
// Auth
|
|
39
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
40
|
+
import { useAuthDialog } from '../../AuthDialog';
|
|
41
|
+
|
|
32
42
|
// Import Django API client
|
|
33
43
|
// @ts-ignore - optional peer dependency
|
|
34
|
-
import {
|
|
44
|
+
import { apiWebPush } from '@djangocfg/api/clients';
|
|
35
45
|
|
|
36
46
|
interface UseDjangoPushOptions extends PushNotificationOptions {
|
|
37
|
-
/**
|
|
38
|
-
* Auto-subscribe on mount if permission granted
|
|
39
|
-
* @default false
|
|
40
|
-
*/
|
|
41
|
-
autoSubscribe?: boolean;
|
|
42
|
-
|
|
43
47
|
/**
|
|
44
48
|
* Callback when subscription created
|
|
45
49
|
*/
|
|
@@ -57,11 +61,13 @@ interface UseDjangoPushOptions extends PushNotificationOptions {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
interface UseDjangoPushReturn {
|
|
60
|
-
// State
|
|
64
|
+
// State (from usePushNotifications)
|
|
61
65
|
isSupported: boolean;
|
|
62
66
|
permission: NotificationPermission;
|
|
63
67
|
isSubscribed: boolean;
|
|
64
68
|
subscription: PushSubscription | null;
|
|
69
|
+
|
|
70
|
+
// Local state
|
|
65
71
|
isLoading: boolean;
|
|
66
72
|
error: Error | null;
|
|
67
73
|
|
|
@@ -75,35 +81,27 @@ interface UseDjangoPushReturn {
|
|
|
75
81
|
* Hook for Django-CFG push notifications integration
|
|
76
82
|
*/
|
|
77
83
|
export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushReturn {
|
|
78
|
-
const {
|
|
79
|
-
autoSubscribe = false,
|
|
80
|
-
onSubscribed,
|
|
81
|
-
onSubscribeError,
|
|
82
|
-
onUnsubscribed,
|
|
83
|
-
...pushOptions
|
|
84
|
-
} = options;
|
|
84
|
+
const { onSubscribed, onSubscribeError, onUnsubscribed, ...pushOptions } = options;
|
|
85
85
|
|
|
86
86
|
const [isLoading, setIsLoading] = useState(false);
|
|
87
87
|
const [error, setError] = useState<Error | null>(null);
|
|
88
88
|
|
|
89
|
-
//
|
|
89
|
+
// Auth check
|
|
90
|
+
const { isAuthenticated } = useAuth();
|
|
91
|
+
const { openAuthDialog } = useAuthDialog();
|
|
92
|
+
|
|
93
|
+
// Use base push notifications hook (handles all browser API)
|
|
90
94
|
const pushNotifications = usePushNotifications(pushOptions);
|
|
91
95
|
|
|
92
96
|
/**
|
|
93
97
|
* Subscribe to push notifications and save to Django backend
|
|
94
98
|
*/
|
|
95
99
|
const subscribe = useCallback(async (): Promise<boolean> => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (!options.vapidPublicKey) {
|
|
104
|
-
const err = new Error('VAPID public key required');
|
|
105
|
-
setError(err);
|
|
106
|
-
onSubscribeError?.(err);
|
|
100
|
+
// Auth check - require login for push subscriptions
|
|
101
|
+
if (!isAuthenticated) {
|
|
102
|
+
openAuthDialog({
|
|
103
|
+
message: 'Please sign in to enable push notifications',
|
|
104
|
+
});
|
|
107
105
|
return false;
|
|
108
106
|
}
|
|
109
107
|
|
|
@@ -111,26 +109,20 @@ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushRetur
|
|
|
111
109
|
setError(null);
|
|
112
110
|
|
|
113
111
|
try {
|
|
114
|
-
// Step 1:
|
|
115
|
-
const
|
|
116
|
-
if (permission !== 'granted') {
|
|
117
|
-
throw new Error(`Permission ${permission}`);
|
|
118
|
-
}
|
|
112
|
+
// Step 1: Browser subscription via usePushNotifications
|
|
113
|
+
const subscription = await pushNotifications.subscribe();
|
|
119
114
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
});
|
|
115
|
+
if (!subscription) {
|
|
116
|
+
// Permission denied or other browser error
|
|
117
|
+
const err = new Error('Browser subscription failed');
|
|
118
|
+
setError(err);
|
|
119
|
+
onSubscribeError?.(err);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
128
122
|
|
|
129
|
-
pwaLogger.info('[useDjangoPush] Browser subscription created
|
|
130
|
-
endpoint: subscription.endpoint.substring(0, 50) + '...',
|
|
131
|
-
});
|
|
123
|
+
pwaLogger.info('[useDjangoPush] Browser subscription created');
|
|
132
124
|
|
|
133
|
-
// Step
|
|
125
|
+
// Step 2: Save to Django backend
|
|
134
126
|
const result = await apiWebPush.web_push.webpushSubscribeCreate({
|
|
135
127
|
endpoint: subscription.endpoint,
|
|
136
128
|
keys: {
|
|
@@ -142,43 +134,47 @@ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushRetur
|
|
|
142
134
|
pwaLogger.info('[useDjangoPush] Subscription saved to Django:', result);
|
|
143
135
|
|
|
144
136
|
onSubscribed?.(subscription);
|
|
137
|
+
toast.success('Push notifications enabled');
|
|
145
138
|
return true;
|
|
146
139
|
} catch (err) {
|
|
147
140
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
148
141
|
pwaLogger.error('[useDjangoPush] Subscribe failed:', error);
|
|
149
142
|
setError(error);
|
|
150
143
|
onSubscribeError?.(error);
|
|
144
|
+
|
|
145
|
+
toast.error('Subscription Failed', {
|
|
146
|
+
description: 'Could not save subscription to server. Please try again.',
|
|
147
|
+
duration: 5000,
|
|
148
|
+
});
|
|
149
|
+
|
|
151
150
|
return false;
|
|
152
151
|
} finally {
|
|
153
152
|
setIsLoading(false);
|
|
154
153
|
}
|
|
155
|
-
}, [
|
|
156
|
-
pushNotifications.isSupported,
|
|
157
|
-
options.vapidPublicKey,
|
|
158
|
-
onSubscribed,
|
|
159
|
-
onSubscribeError,
|
|
160
|
-
]);
|
|
154
|
+
}, [isAuthenticated, openAuthDialog, pushNotifications.subscribe, onSubscribed, onSubscribeError]);
|
|
161
155
|
|
|
162
156
|
/**
|
|
163
157
|
* Unsubscribe from push notifications
|
|
164
158
|
*/
|
|
165
159
|
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
|
166
|
-
if (!pushNotifications.subscription) {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
160
|
setIsLoading(true);
|
|
171
161
|
setError(null);
|
|
172
162
|
|
|
173
163
|
try {
|
|
174
|
-
// Unsubscribe
|
|
175
|
-
await pushNotifications.
|
|
164
|
+
// Unsubscribe via usePushNotifications (handles browser API)
|
|
165
|
+
const success = await pushNotifications.unsubscribe();
|
|
166
|
+
|
|
167
|
+
if (!success) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
176
171
|
pwaLogger.info('[useDjangoPush] Browser unsubscribed');
|
|
177
172
|
|
|
178
|
-
// Note: Django backend
|
|
179
|
-
//
|
|
173
|
+
// Note: Django backend auto-cleans up inactive subscriptions
|
|
174
|
+
// when push delivery fails (410 Gone response)
|
|
180
175
|
|
|
181
176
|
onUnsubscribed?.();
|
|
177
|
+
toast.success('Push notifications disabled');
|
|
182
178
|
return true;
|
|
183
179
|
} catch (err) {
|
|
184
180
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -188,7 +184,7 @@ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushRetur
|
|
|
188
184
|
} finally {
|
|
189
185
|
setIsLoading(false);
|
|
190
186
|
}
|
|
191
|
-
}, [pushNotifications.
|
|
187
|
+
}, [pushNotifications.unsubscribe, onUnsubscribed]);
|
|
192
188
|
|
|
193
189
|
/**
|
|
194
190
|
* Send test push notification
|
|
@@ -227,11 +223,13 @@ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushRetur
|
|
|
227
223
|
);
|
|
228
224
|
|
|
229
225
|
return {
|
|
230
|
-
// State
|
|
226
|
+
// State from usePushNotifications
|
|
231
227
|
isSupported: pushNotifications.isSupported,
|
|
232
228
|
permission: pushNotifications.permission,
|
|
233
229
|
isSubscribed: pushNotifications.isSubscribed,
|
|
234
230
|
subscription: pushNotifications.subscription,
|
|
231
|
+
|
|
232
|
+
// Local state
|
|
235
233
|
isLoading,
|
|
236
234
|
error,
|
|
237
235
|
|
|
@@ -246,22 +244,6 @@ export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushRetur
|
|
|
246
244
|
// Utilities
|
|
247
245
|
// ============================================================================
|
|
248
246
|
|
|
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
247
|
/**
|
|
266
248
|
* Convert ArrayBuffer to base64 string
|
|
267
249
|
*/
|
|
@@ -49,15 +49,19 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
49
49
|
}
|
|
50
50
|
}, []);
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Subscribe to push notifications
|
|
54
|
+
* @returns subscription on success, null on failure
|
|
55
|
+
*/
|
|
56
|
+
const subscribe = async (): Promise<PushSubscription | null> => {
|
|
53
57
|
if (!state.isSupported) {
|
|
54
58
|
pwaLogger.warn('[usePushNotifications] Push notifications not supported');
|
|
55
|
-
return
|
|
59
|
+
return null;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
if (!options?.vapidPublicKey) {
|
|
59
63
|
pwaLogger.error('[usePushNotifications] VAPID public key required');
|
|
60
|
-
return
|
|
64
|
+
return null;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
try {
|
|
@@ -76,7 +80,7 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
76
80
|
|
|
77
81
|
if (permission !== 'granted') {
|
|
78
82
|
pwaLogger.warn('[usePushNotifications] Permission not granted:', permission);
|
|
79
|
-
return
|
|
83
|
+
return null;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
// Subscribe to push
|
|
@@ -94,7 +98,7 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
94
98
|
} else {
|
|
95
99
|
pwaLogger.error('[usePushNotifications] Failed to convert VAPID key:', e);
|
|
96
100
|
}
|
|
97
|
-
return
|
|
101
|
+
return null;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
// Diagnostic logging (only in debug mode)
|
|
@@ -122,7 +126,7 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
122
126
|
// Attempt subscribe
|
|
123
127
|
const subscription = await registration.pushManager.subscribe(subscribeOptions);
|
|
124
128
|
|
|
125
|
-
// Send subscription to server
|
|
129
|
+
// Send subscription to server (legacy endpoint support)
|
|
126
130
|
if (options.subscribeEndpoint) {
|
|
127
131
|
await fetch(options.subscribeEndpoint, {
|
|
128
132
|
method: 'POST',
|
|
@@ -138,7 +142,7 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
138
142
|
}));
|
|
139
143
|
|
|
140
144
|
pwaLogger.success('[usePushNotifications] Successfully subscribed to push notifications');
|
|
141
|
-
return
|
|
145
|
+
return subscription;
|
|
142
146
|
} catch (error: any) {
|
|
143
147
|
pwaLogger.error('[usePushNotifications] Subscribe failed:', error);
|
|
144
148
|
|
|
@@ -172,7 +176,7 @@ export function usePushNotifications(options?: PushNotificationOptions) {
|
|
|
172
176
|
pwaLogger.error('Unknown error:', error.name, error.message);
|
|
173
177
|
}
|
|
174
178
|
|
|
175
|
-
return
|
|
179
|
+
return null;
|
|
176
180
|
}
|
|
177
181
|
};
|
|
178
182
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Notifications Snippet
|
|
3
|
+
*
|
|
4
|
+
* Web Push Notifications management with Django-CFG integration
|
|
5
|
+
* Includes push history, subscription management, and Django API integration
|
|
6
|
+
*
|
|
7
|
+
* @example Usage
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { DjangoPushProvider, useDjangoPushContext } from '@/snippets/PushNotifications';
|
|
10
|
+
*
|
|
11
|
+
* // In layout
|
|
12
|
+
* export default function Layout({ children }) {
|
|
13
|
+
* return (
|
|
14
|
+
* <DjangoPushProvider
|
|
15
|
+
* vapidPublicKey={process.env.NEXT_PUBLIC_VAPID_KEY}
|
|
16
|
+
* autoSubscribe={false}
|
|
17
|
+
* >
|
|
18
|
+
* {children}
|
|
19
|
+
* <PushPrompt requirePWA={true} />
|
|
20
|
+
* </DjangoPushProvider>
|
|
21
|
+
* );
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* // In component
|
|
25
|
+
* function PushDemo() {
|
|
26
|
+
* const { isSubscribed, pushes, subscribe, sendPush } = useDjangoPushContext();
|
|
27
|
+
*
|
|
28
|
+
* return (
|
|
29
|
+
* <div>
|
|
30
|
+
* {!isSubscribed && <button onClick={subscribe}>Subscribe</button>}
|
|
31
|
+
* <div>Received {pushes.length} notifications</div>
|
|
32
|
+
* </div>
|
|
33
|
+
* );
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @note For backwards compatibility, you can also use `PushProvider` and `usePush`
|
|
38
|
+
* aliases which point to the same Django implementation.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// Main API - Django Push Integration
|
|
42
|
+
export { DjangoPushProvider, useDjangoPushContext } from './context/DjangoPushContext';
|
|
43
|
+
export type { PushMessage } from './context/DjangoPushContext';
|
|
44
|
+
|
|
45
|
+
// Backwards compatibility aliases
|
|
46
|
+
export { DjangoPushProvider as PushProvider } from './context/DjangoPushContext';
|
|
47
|
+
export { useDjangoPushContext as usePush } from './context/DjangoPushContext';
|
|
48
|
+
|
|
49
|
+
// Components
|
|
50
|
+
export { PushPrompt } from './components/PushPrompt';
|
|
51
|
+
|
|
52
|
+
// Hooks
|
|
53
|
+
export { usePushNotifications } from './hooks/usePushNotifications';
|
|
54
|
+
export { useDjangoPush } from './hooks/useDjangoPush';
|
|
55
|
+
|
|
56
|
+
// Config
|
|
57
|
+
export { DEFAULT_VAPID_PUBLIC_KEY } from './config';
|
|
58
|
+
|
|
59
|
+
// Utilities
|
|
60
|
+
export {
|
|
61
|
+
urlBase64ToUint8Array,
|
|
62
|
+
isValidVapidKey,
|
|
63
|
+
getVapidKeyInfo,
|
|
64
|
+
safeUrlBase64ToUint8Array,
|
|
65
|
+
VapidKeyError,
|
|
66
|
+
type VapidKeyErrorCode,
|
|
67
|
+
} from './utils/vapid';
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
pwaLogger,
|
|
71
|
+
enablePWADebug,
|
|
72
|
+
disablePWADebug,
|
|
73
|
+
isPWADebugEnabled,
|
|
74
|
+
} from './utils/logger';
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
STORAGE_KEYS,
|
|
78
|
+
markPushDismissed,
|
|
79
|
+
isPushDismissedRecently,
|
|
80
|
+
clearAllPushData,
|
|
81
|
+
} from './utils/localStorage';
|
|
82
|
+
|
|
83
|
+
// Types - Configuration
|
|
84
|
+
export type { PushNotificationsConfig } from './types';
|
|
85
|
+
|
|
86
|
+
// Types
|
|
87
|
+
export type { PushNotificationState, PushNotificationOptions } from './types';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Notifications Configuration Types
|
|
3
|
+
*
|
|
4
|
+
* Configuration for Web Push Notifications with VAPID authentication
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface PushNotificationsConfig {
|
|
8
|
+
/** Enable push notifications */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
|
|
11
|
+
/** VAPID public key for push notifications (required when enabled) */
|
|
12
|
+
vapidPublicKey: string;
|
|
13
|
+
|
|
14
|
+
/** API endpoint for subscription (default: '/api/push/subscribe') */
|
|
15
|
+
subscribeEndpoint?: string;
|
|
16
|
+
|
|
17
|
+
/** Only show push prompt if PWA is installed (default: true) */
|
|
18
|
+
requirePWA?: boolean;
|
|
19
|
+
|
|
20
|
+
/** Delay before showing push prompt after PWA install (ms) */
|
|
21
|
+
delayMs?: number;
|
|
22
|
+
|
|
23
|
+
/** Number of days before re-showing dismissed push prompt */
|
|
24
|
+
resetAfterDays?: number;
|
|
25
|
+
|
|
26
|
+
/** Auto-subscribe on mount if permission already granted */
|
|
27
|
+
autoSubscribe?: boolean;
|
|
28
|
+
}
|