@djangocfg/layouts 2.1.36 → 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 +105 -28
- 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/McpChat/context/ChatContext.tsx +9 -0
- package/src/snippets/PWAInstall/@docs/README.md +92 -0
- package/src/snippets/PWAInstall/@docs/research/ios-android-install-flows.md +576 -0
- package/src/snippets/PWAInstall/README.md +185 -0
- package/src/snippets/PWAInstall/components/A2HSHint.tsx +227 -0
- package/src/snippets/PWAInstall/components/DesktopGuide.tsx +229 -0
- package/src/snippets/PWAInstall/components/IOSGuide.tsx +29 -0
- package/src/snippets/PWAInstall/components/IOSGuideDrawer.tsx +101 -0
- package/src/snippets/PWAInstall/components/IOSGuideModal.tsx +101 -0
- package/src/snippets/PWAInstall/context/InstallContext.tsx +102 -0
- package/src/snippets/PWAInstall/hooks/useInstallPrompt.ts +167 -0
- package/src/snippets/PWAInstall/hooks/useIsPWA.ts +115 -0
- package/src/snippets/PWAInstall/index.ts +76 -0
- package/src/snippets/PWAInstall/types/components.ts +95 -0
- package/src/snippets/PWAInstall/types/config.ts +22 -0
- package/src/snippets/PWAInstall/types/index.ts +26 -0
- package/src/snippets/PWAInstall/types/install.ts +38 -0
- package/src/snippets/PWAInstall/types/platform.ts +29 -0
- package/src/snippets/PWAInstall/utils/localStorage.ts +181 -0
- package/src/snippets/PWAInstall/utils/logger.ts +149 -0
- package/src/snippets/PWAInstall/utils/platform.ts +151 -0
- 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/PushNotifications/components/PushPrompt.tsx +165 -0
- package/src/snippets/PushNotifications/config.ts +20 -0
- package/src/snippets/PushNotifications/context/DjangoPushContext.tsx +190 -0
- package/src/snippets/PushNotifications/hooks/useDjangoPush.ts +259 -0
- package/src/snippets/PushNotifications/hooks/usePushNotifications.ts +209 -0
- 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/types/push.ts +21 -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/PushNotifications/utils/vapid.ts +226 -0
- package/src/snippets/index.ts +55 -0
- package/src/layouts/shared/index.ts +0 -21
- package/src/layouts/shared/types.ts +0 -211
- /package/src/layouts/{shared → types}/README.md +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Push Notification Prompt
|
|
5
|
+
*
|
|
6
|
+
* Shows after PWA is installed to request push notification permission
|
|
7
|
+
* Auto-dismisses after user action or timeout
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useState, useEffect } from 'react';
|
|
11
|
+
import { Bell, X } from 'lucide-react';
|
|
12
|
+
import { Button } from '@djangocfg/ui-nextjs';
|
|
13
|
+
|
|
14
|
+
import { usePushNotifications } from '../hooks/usePushNotifications';
|
|
15
|
+
import { isStandalone } from '../utils/platform';
|
|
16
|
+
import { pwaLogger } from '../utils/logger';
|
|
17
|
+
import { markPushDismissed, isPushDismissedRecently } from '../utils/localStorage';
|
|
18
|
+
import type { PushNotificationOptions } from '../types';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_RESET_DAYS = 7;
|
|
21
|
+
|
|
22
|
+
interface PushPromptProps extends PushNotificationOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Only show if PWA is installed
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
requirePWA?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delay before showing prompt (ms)
|
|
31
|
+
* @default 5000
|
|
32
|
+
*/
|
|
33
|
+
delayMs?: number;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Number of days before re-showing dismissed prompt
|
|
37
|
+
* @default 7
|
|
38
|
+
*/
|
|
39
|
+
resetAfterDays?: number;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Callback when push is enabled
|
|
43
|
+
*/
|
|
44
|
+
onEnabled?: () => void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Callback when push is dismissed
|
|
48
|
+
*/
|
|
49
|
+
onDismissed?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function PushPrompt({
|
|
53
|
+
vapidPublicKey,
|
|
54
|
+
subscribeEndpoint = '/api/push/subscribe',
|
|
55
|
+
requirePWA = true,
|
|
56
|
+
delayMs = 5000,
|
|
57
|
+
resetAfterDays = DEFAULT_RESET_DAYS,
|
|
58
|
+
onEnabled,
|
|
59
|
+
onDismissed,
|
|
60
|
+
}: PushPromptProps) {
|
|
61
|
+
const { isSupported, permission, isSubscribed, subscribe } = usePushNotifications({
|
|
62
|
+
vapidPublicKey,
|
|
63
|
+
subscribeEndpoint,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const [show, setShow] = useState(false);
|
|
67
|
+
const [enabling, setEnabling] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Check if should show
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!isSupported || isSubscribed || permission === 'denied') {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if PWA is installed (standalone mode)
|
|
76
|
+
if (requirePWA && !isStandalone()) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check if previously dismissed
|
|
81
|
+
if (typeof window !== 'undefined') {
|
|
82
|
+
if (isPushDismissedRecently(resetAfterDays)) {
|
|
83
|
+
return; // Still within reset period
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Show after delay
|
|
88
|
+
const timer = setTimeout(() => setShow(true), delayMs);
|
|
89
|
+
return () => clearTimeout(timer);
|
|
90
|
+
}, [isSupported, isSubscribed, permission, requirePWA, resetAfterDays, delayMs]);
|
|
91
|
+
|
|
92
|
+
const handleEnable = async () => {
|
|
93
|
+
setEnabling(true);
|
|
94
|
+
try {
|
|
95
|
+
const success = await subscribe();
|
|
96
|
+
if (success) {
|
|
97
|
+
setShow(false);
|
|
98
|
+
onEnabled?.();
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
pwaLogger.error('[PushPrompt] Enable failed:', error);
|
|
102
|
+
} finally {
|
|
103
|
+
setEnabling(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleDismiss = () => {
|
|
108
|
+
setShow(false);
|
|
109
|
+
markPushDismissed();
|
|
110
|
+
onDismissed?.();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (!show) return null;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="fixed bottom-4 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 duration-300">
|
|
117
|
+
<div className="bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-lg">
|
|
118
|
+
<div className="flex items-start gap-3">
|
|
119
|
+
{/* Icon */}
|
|
120
|
+
<div className="flex-shrink-0">
|
|
121
|
+
<Bell className="w-5 h-5 text-blue-400" />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Content */}
|
|
125
|
+
<div className="flex-1 min-w-0">
|
|
126
|
+
<p className="text-sm font-medium text-white mb-1">Enable notifications</p>
|
|
127
|
+
<p className="text-xs text-zinc-400 mb-3">
|
|
128
|
+
Stay updated with important updates and alerts
|
|
129
|
+
</p>
|
|
130
|
+
|
|
131
|
+
{/* Actions */}
|
|
132
|
+
<div className="flex gap-2">
|
|
133
|
+
<Button
|
|
134
|
+
onClick={handleEnable}
|
|
135
|
+
loading={enabling}
|
|
136
|
+
size="sm"
|
|
137
|
+
variant="default"
|
|
138
|
+
>
|
|
139
|
+
Enable
|
|
140
|
+
</Button>
|
|
141
|
+
<Button
|
|
142
|
+
onClick={handleDismiss}
|
|
143
|
+
size="sm"
|
|
144
|
+
variant="ghost"
|
|
145
|
+
>
|
|
146
|
+
Not now
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Close button */}
|
|
152
|
+
<Button
|
|
153
|
+
onClick={handleDismiss}
|
|
154
|
+
size="sm"
|
|
155
|
+
variant="ghost"
|
|
156
|
+
className="flex-shrink-0 p-1"
|
|
157
|
+
aria-label="Dismiss"
|
|
158
|
+
>
|
|
159
|
+
<X className="w-4 h-4" />
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Notifications Configuration
|
|
3
|
+
*
|
|
4
|
+
* Centralized constants for push notifications functionality.
|
|
5
|
+
*
|
|
6
|
+
* SECURITY NOTE:
|
|
7
|
+
* - VAPID_PRIVATE_KEY should NEVER be in frontend code
|
|
8
|
+
* - Private keys must only exist in backend/API routes
|
|
9
|
+
* - Frontend only needs the public key (NEXT_PUBLIC_* env vars)
|
|
10
|
+
* - VAPID_MAILTO should also remain on backend only
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Default VAPID public key (safe to expose in frontend)
|
|
14
|
+
export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
|
|
15
|
+
|
|
16
|
+
// NOTE: VAPID private key and mailto should only exist in:
|
|
17
|
+
// - Backend environment variables (not NEXT_PUBLIC_*)
|
|
18
|
+
// - API route handlers (app/api/push/*)
|
|
19
|
+
// - Service worker generation scripts
|
|
20
|
+
// NEVER import or use private keys in frontend code
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Django Push Notifications Hook
|
|
5
|
+
*
|
|
6
|
+
* Integrates usePushNotifications with Django-CFG backend API.
|
|
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
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* import { useDjangoPush } from '@djangocfg/layouts/snippets';
|
|
17
|
+
*
|
|
18
|
+
* function NotificationsButton() {
|
|
19
|
+
* const { subscribe, unsubscribe, isSubscribed } = useDjangoPush({
|
|
20
|
+
* vapidPublicKey: 'YOUR_VAPID_KEY'
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* return (
|
|
24
|
+
* <button onClick={isSubscribed ? unsubscribe : subscribe}>
|
|
25
|
+
* {isSubscribed ? 'Unsubscribe' : 'Subscribe'}
|
|
26
|
+
* </button>
|
|
27
|
+
* );
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { useState, useCallback } from 'react';
|
|
33
|
+
import { toast } from 'sonner';
|
|
34
|
+
import { usePushNotifications } from './usePushNotifications';
|
|
35
|
+
import { pwaLogger } from '../utils/logger';
|
|
36
|
+
import type { PushNotificationOptions } from '../types';
|
|
37
|
+
|
|
38
|
+
// Auth
|
|
39
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
40
|
+
import { useAuthDialog } from '../../AuthDialog';
|
|
41
|
+
|
|
42
|
+
// Import Django API client
|
|
43
|
+
// @ts-ignore - optional peer dependency
|
|
44
|
+
import { apiWebPush } from '@djangocfg/api/clients';
|
|
45
|
+
|
|
46
|
+
interface UseDjangoPushOptions extends PushNotificationOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Callback when subscription created
|
|
49
|
+
*/
|
|
50
|
+
onSubscribed?: (subscription: PushSubscription) => void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Callback when subscription failed
|
|
54
|
+
*/
|
|
55
|
+
onSubscribeError?: (error: Error) => void;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Callback when unsubscribed
|
|
59
|
+
*/
|
|
60
|
+
onUnsubscribed?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface UseDjangoPushReturn {
|
|
64
|
+
// State (from usePushNotifications)
|
|
65
|
+
isSupported: boolean;
|
|
66
|
+
permission: NotificationPermission;
|
|
67
|
+
isSubscribed: boolean;
|
|
68
|
+
subscription: PushSubscription | null;
|
|
69
|
+
|
|
70
|
+
// Local state
|
|
71
|
+
isLoading: boolean;
|
|
72
|
+
error: Error | null;
|
|
73
|
+
|
|
74
|
+
// Actions
|
|
75
|
+
subscribe: () => Promise<boolean>;
|
|
76
|
+
unsubscribe: () => Promise<boolean>;
|
|
77
|
+
sendTestPush: (message: { title: string; body: string; url?: string }) => Promise<boolean>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hook for Django-CFG push notifications integration
|
|
82
|
+
*/
|
|
83
|
+
export function useDjangoPush(options: UseDjangoPushOptions): UseDjangoPushReturn {
|
|
84
|
+
const { onSubscribed, onSubscribeError, onUnsubscribed, ...pushOptions } = options;
|
|
85
|
+
|
|
86
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
87
|
+
const [error, setError] = useState<Error | null>(null);
|
|
88
|
+
|
|
89
|
+
// Auth check
|
|
90
|
+
const { isAuthenticated } = useAuth();
|
|
91
|
+
const { openAuthDialog } = useAuthDialog();
|
|
92
|
+
|
|
93
|
+
// Use base push notifications hook (handles all browser API)
|
|
94
|
+
const pushNotifications = usePushNotifications(pushOptions);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Subscribe to push notifications and save to Django backend
|
|
98
|
+
*/
|
|
99
|
+
const subscribe = useCallback(async (): Promise<boolean> => {
|
|
100
|
+
// Auth check - require login for push subscriptions
|
|
101
|
+
if (!isAuthenticated) {
|
|
102
|
+
openAuthDialog({
|
|
103
|
+
message: 'Please sign in to enable push notifications',
|
|
104
|
+
});
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setIsLoading(true);
|
|
109
|
+
setError(null);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Step 1: Browser subscription via usePushNotifications
|
|
113
|
+
const subscription = await pushNotifications.subscribe();
|
|
114
|
+
|
|
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
|
+
}
|
|
122
|
+
|
|
123
|
+
pwaLogger.info('[useDjangoPush] Browser subscription created');
|
|
124
|
+
|
|
125
|
+
// Step 2: Save to Django backend
|
|
126
|
+
const result = await apiWebPush.web_push.webpushSubscribeCreate({
|
|
127
|
+
endpoint: subscription.endpoint,
|
|
128
|
+
keys: {
|
|
129
|
+
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
|
|
130
|
+
auth: arrayBufferToBase64(subscription.getKey('auth')),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pwaLogger.info('[useDjangoPush] Subscription saved to Django:', result);
|
|
135
|
+
|
|
136
|
+
onSubscribed?.(subscription);
|
|
137
|
+
toast.success('Push notifications enabled');
|
|
138
|
+
return true;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
141
|
+
pwaLogger.error('[useDjangoPush] Subscribe failed:', error);
|
|
142
|
+
setError(error);
|
|
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
|
+
|
|
150
|
+
return false;
|
|
151
|
+
} finally {
|
|
152
|
+
setIsLoading(false);
|
|
153
|
+
}
|
|
154
|
+
}, [isAuthenticated, openAuthDialog, pushNotifications.subscribe, onSubscribed, onSubscribeError]);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Unsubscribe from push notifications
|
|
158
|
+
*/
|
|
159
|
+
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
|
160
|
+
setIsLoading(true);
|
|
161
|
+
setError(null);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Unsubscribe via usePushNotifications (handles browser API)
|
|
165
|
+
const success = await pushNotifications.unsubscribe();
|
|
166
|
+
|
|
167
|
+
if (!success) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pwaLogger.info('[useDjangoPush] Browser unsubscribed');
|
|
172
|
+
|
|
173
|
+
// Note: Django backend auto-cleans up inactive subscriptions
|
|
174
|
+
// when push delivery fails (410 Gone response)
|
|
175
|
+
|
|
176
|
+
onUnsubscribed?.();
|
|
177
|
+
toast.success('Push notifications disabled');
|
|
178
|
+
return true;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
181
|
+
pwaLogger.error('[useDjangoPush] Unsubscribe failed:', error);
|
|
182
|
+
setError(error);
|
|
183
|
+
return false;
|
|
184
|
+
} finally {
|
|
185
|
+
setIsLoading(false);
|
|
186
|
+
}
|
|
187
|
+
}, [pushNotifications.unsubscribe, onUnsubscribed]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send test push notification
|
|
191
|
+
*/
|
|
192
|
+
const sendTestPush = useCallback(
|
|
193
|
+
async (message: { title: string; body: string; url?: string }): Promise<boolean> => {
|
|
194
|
+
if (!pushNotifications.isSubscribed) {
|
|
195
|
+
const err = new Error('Not subscribed');
|
|
196
|
+
setError(err);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setIsLoading(true);
|
|
201
|
+
setError(null);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await apiWebPush.web_push.webpushSendCreate({
|
|
205
|
+
title: message.title,
|
|
206
|
+
body: message.body,
|
|
207
|
+
url: message.url || '/',
|
|
208
|
+
icon: '/icon.png',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
pwaLogger.info('[useDjangoPush] Test push sent:', result);
|
|
212
|
+
return result.success;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
215
|
+
pwaLogger.error('[useDjangoPush] Send test push failed:', error);
|
|
216
|
+
setError(error);
|
|
217
|
+
return false;
|
|
218
|
+
} finally {
|
|
219
|
+
setIsLoading(false);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
[pushNotifications.isSubscribed]
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
// State from usePushNotifications
|
|
227
|
+
isSupported: pushNotifications.isSupported,
|
|
228
|
+
permission: pushNotifications.permission,
|
|
229
|
+
isSubscribed: pushNotifications.isSubscribed,
|
|
230
|
+
subscription: pushNotifications.subscription,
|
|
231
|
+
|
|
232
|
+
// Local state
|
|
233
|
+
isLoading,
|
|
234
|
+
error,
|
|
235
|
+
|
|
236
|
+
// Actions
|
|
237
|
+
subscribe,
|
|
238
|
+
unsubscribe,
|
|
239
|
+
sendTestPush,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// Utilities
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Convert ArrayBuffer to base64 string
|
|
249
|
+
*/
|
|
250
|
+
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
|
|
251
|
+
if (!buffer) return '';
|
|
252
|
+
|
|
253
|
+
const bytes = new Uint8Array(buffer);
|
|
254
|
+
let binary = '';
|
|
255
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
256
|
+
binary += String.fromCharCode(bytes[i]);
|
|
257
|
+
}
|
|
258
|
+
return window.btoa(binary);
|
|
259
|
+
}
|