@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.
Files changed (64) hide show
  1. package/README.md +204 -18
  2. package/package.json +5 -5
  3. package/src/components/errors/index.ts +9 -0
  4. package/src/components/errors/types.ts +38 -0
  5. package/src/layouts/AppLayout/AppLayout.tsx +33 -45
  6. package/src/layouts/AppLayout/BaseApp.tsx +105 -28
  7. package/src/layouts/AuthLayout/AuthContext.tsx +7 -1
  8. package/src/layouts/AuthLayout/OAuthProviders.tsx +1 -10
  9. package/src/layouts/AuthLayout/OTPForm.tsx +1 -0
  10. package/src/layouts/PrivateLayout/PrivateLayout.tsx +1 -1
  11. package/src/layouts/PublicLayout/PublicLayout.tsx +1 -1
  12. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
  13. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +1 -1
  14. package/src/layouts/_components/UserMenu.tsx +1 -1
  15. package/src/layouts/index.ts +1 -1
  16. package/src/layouts/types/index.ts +47 -0
  17. package/src/layouts/types/layout.types.ts +61 -0
  18. package/src/layouts/types/providers.types.ts +65 -0
  19. package/src/layouts/types/ui.types.ts +103 -0
  20. package/src/snippets/Analytics/index.ts +1 -0
  21. package/src/snippets/Analytics/types.ts +10 -0
  22. package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
  23. package/src/snippets/PWAInstall/@docs/README.md +92 -0
  24. package/src/snippets/PWAInstall/@docs/research/ios-android-install-flows.md +576 -0
  25. package/src/snippets/PWAInstall/README.md +185 -0
  26. package/src/snippets/PWAInstall/components/A2HSHint.tsx +227 -0
  27. package/src/snippets/PWAInstall/components/DesktopGuide.tsx +229 -0
  28. package/src/snippets/PWAInstall/components/IOSGuide.tsx +29 -0
  29. package/src/snippets/PWAInstall/components/IOSGuideDrawer.tsx +101 -0
  30. package/src/snippets/PWAInstall/components/IOSGuideModal.tsx +101 -0
  31. package/src/snippets/PWAInstall/context/InstallContext.tsx +102 -0
  32. package/src/snippets/PWAInstall/hooks/useInstallPrompt.ts +167 -0
  33. package/src/snippets/PWAInstall/hooks/useIsPWA.ts +115 -0
  34. package/src/snippets/PWAInstall/index.ts +76 -0
  35. package/src/snippets/PWAInstall/types/components.ts +95 -0
  36. package/src/snippets/PWAInstall/types/config.ts +22 -0
  37. package/src/snippets/PWAInstall/types/index.ts +26 -0
  38. package/src/snippets/PWAInstall/types/install.ts +38 -0
  39. package/src/snippets/PWAInstall/types/platform.ts +29 -0
  40. package/src/snippets/PWAInstall/utils/localStorage.ts +181 -0
  41. package/src/snippets/PWAInstall/utils/logger.ts +149 -0
  42. package/src/snippets/PWAInstall/utils/platform.ts +151 -0
  43. package/src/snippets/PushNotifications/@docs/README.md +191 -0
  44. package/src/snippets/PushNotifications/@docs/guides/django-integration.md +648 -0
  45. package/src/snippets/PushNotifications/@docs/guides/service-worker.md +467 -0
  46. package/src/snippets/PushNotifications/@docs/guides/vapid-setup.md +352 -0
  47. package/src/snippets/PushNotifications/README.md +328 -0
  48. package/src/snippets/PushNotifications/components/PushPrompt.tsx +165 -0
  49. package/src/snippets/PushNotifications/config.ts +20 -0
  50. package/src/snippets/PushNotifications/context/DjangoPushContext.tsx +190 -0
  51. package/src/snippets/PushNotifications/hooks/useDjangoPush.ts +259 -0
  52. package/src/snippets/PushNotifications/hooks/usePushNotifications.ts +209 -0
  53. package/src/snippets/PushNotifications/index.ts +87 -0
  54. package/src/snippets/PushNotifications/types/config.ts +28 -0
  55. package/src/snippets/PushNotifications/types/index.ts +9 -0
  56. package/src/snippets/PushNotifications/types/push.ts +21 -0
  57. package/src/snippets/PushNotifications/utils/localStorage.ts +60 -0
  58. package/src/snippets/PushNotifications/utils/logger.ts +149 -0
  59. package/src/snippets/PushNotifications/utils/platform.ts +151 -0
  60. package/src/snippets/PushNotifications/utils/vapid.ts +226 -0
  61. package/src/snippets/index.ts +55 -0
  62. package/src/layouts/shared/index.ts +0 -21
  63. package/src/layouts/shared/types.ts +0 -211
  64. /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
+ }