@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,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS Installation Guide Drawer (Mobile-Optimized)
|
|
5
|
+
*
|
|
6
|
+
* Bottom drawer with swipe gestures for iOS installation guide
|
|
7
|
+
* Better UX for mobile devices
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { Share, Check, ArrowUpRight, ArrowDown, CheckCircle } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
Drawer,
|
|
15
|
+
DrawerContent,
|
|
16
|
+
DrawerDescription,
|
|
17
|
+
DrawerHeader,
|
|
18
|
+
DrawerTitle,
|
|
19
|
+
Button,
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
} from '@djangocfg/ui-nextjs';
|
|
23
|
+
|
|
24
|
+
import type { IOSGuideModalProps, InstallStep } from '../types';
|
|
25
|
+
|
|
26
|
+
const steps: InstallStep[] = [
|
|
27
|
+
{
|
|
28
|
+
number: 1,
|
|
29
|
+
title: 'Tap Share',
|
|
30
|
+
icon: ArrowUpRight,
|
|
31
|
+
description: 'At the bottom of Safari',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
number: 2,
|
|
35
|
+
title: 'Scroll & Tap',
|
|
36
|
+
icon: ArrowDown,
|
|
37
|
+
description: '"Add to Home Screen"',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
number: 3,
|
|
41
|
+
title: 'Confirm',
|
|
42
|
+
icon: CheckCircle,
|
|
43
|
+
description: 'Tap "Add" in top-right',
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
48
|
+
return (
|
|
49
|
+
<Card className="border border-border">
|
|
50
|
+
<CardContent className="p-4">
|
|
51
|
+
<div className="flex items-start gap-3">
|
|
52
|
+
<div
|
|
53
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
54
|
+
style={{ width: '32px', height: '32px' }}
|
|
55
|
+
>
|
|
56
|
+
<span className="text-sm font-semibold">{step.number}</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="flex-1 min-w-0">
|
|
60
|
+
<div className="flex items-center gap-2 mb-1">
|
|
61
|
+
<step.icon className="w-5 h-5 text-primary" />
|
|
62
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
63
|
+
</div>
|
|
64
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function IOSGuideDrawer({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
73
|
+
return (
|
|
74
|
+
<Drawer open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
75
|
+
<DrawerContent>
|
|
76
|
+
<DrawerHeader className="text-left">
|
|
77
|
+
<DrawerTitle className="flex items-center gap-2">
|
|
78
|
+
<Share className="w-5 h-5 text-primary" />
|
|
79
|
+
Add to Home Screen
|
|
80
|
+
</DrawerTitle>
|
|
81
|
+
<DrawerDescription className="text-left">
|
|
82
|
+
Install this app on your iPhone for quick access and a better experience
|
|
83
|
+
</DrawerDescription>
|
|
84
|
+
</DrawerHeader>
|
|
85
|
+
|
|
86
|
+
<div className="space-y-3 p-4">
|
|
87
|
+
{steps.map((step) => (
|
|
88
|
+
<StepCard key={step.number} step={step} />
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="p-4 pt-0">
|
|
93
|
+
<Button onClick={onDismiss} variant="default" className="w-full">
|
|
94
|
+
<Check className="w-4 h-4 mr-2" />
|
|
95
|
+
Got It
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
</DrawerContent>
|
|
99
|
+
</Drawer>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS Installation Guide Modal
|
|
5
|
+
*
|
|
6
|
+
* Visual step-by-step guide for adding PWA to home screen on iOS Safari
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { Share, Check, ArrowUpRight, ArrowDown, CheckCircle } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Dialog,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogDescription,
|
|
16
|
+
DialogFooter,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
Button,
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
} from '@djangocfg/ui-nextjs';
|
|
23
|
+
|
|
24
|
+
import type { IOSGuideModalProps, InstallStep } from '../types';
|
|
25
|
+
|
|
26
|
+
const steps: InstallStep[] = [
|
|
27
|
+
{
|
|
28
|
+
number: 1,
|
|
29
|
+
title: 'Tap Share',
|
|
30
|
+
icon: ArrowUpRight,
|
|
31
|
+
description: 'At the bottom of Safari',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
number: 2,
|
|
35
|
+
title: 'Scroll & Tap',
|
|
36
|
+
icon: ArrowDown,
|
|
37
|
+
description: '"Add to Home Screen"',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
number: 3,
|
|
41
|
+
title: 'Confirm',
|
|
42
|
+
icon: CheckCircle,
|
|
43
|
+
description: 'Tap "Add" in top-right',
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
48
|
+
return (
|
|
49
|
+
<Card className="border border-border">
|
|
50
|
+
<CardContent className="p-4">
|
|
51
|
+
<div className="flex items-start gap-3">
|
|
52
|
+
<div
|
|
53
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
54
|
+
style={{ width: '32px', height: '32px' }}
|
|
55
|
+
>
|
|
56
|
+
<span className="text-sm font-semibold">{step.number}</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="flex-1 min-w-0">
|
|
60
|
+
<div className="flex items-center gap-2 mb-1">
|
|
61
|
+
<step.icon className="w-5 h-5 text-primary" />
|
|
62
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
63
|
+
</div>
|
|
64
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function IOSGuideModal({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
73
|
+
return (
|
|
74
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
75
|
+
<DialogContent className="sm:max-w-md">
|
|
76
|
+
<DialogHeader className="text-left">
|
|
77
|
+
<DialogTitle className="flex items-center gap-2">
|
|
78
|
+
<Share className="w-5 h-5 text-primary" />
|
|
79
|
+
Add to Home Screen
|
|
80
|
+
</DialogTitle>
|
|
81
|
+
<DialogDescription className="text-left">
|
|
82
|
+
Install this app on your iPhone for quick access and a better experience
|
|
83
|
+
</DialogDescription>
|
|
84
|
+
</DialogHeader>
|
|
85
|
+
|
|
86
|
+
<div className="space-y-3 py-4">
|
|
87
|
+
{steps.map((step) => (
|
|
88
|
+
<StepCard key={step.number} step={step} />
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<DialogFooter>
|
|
93
|
+
<Button onClick={onDismiss} variant="default" className="w-full">
|
|
94
|
+
<Check className="w-4 h-4 mr-2" />
|
|
95
|
+
Got It
|
|
96
|
+
</Button>
|
|
97
|
+
</DialogFooter>
|
|
98
|
+
</DialogContent>
|
|
99
|
+
</Dialog>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -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
|
+
* PWA Configuration
|
|
3
|
+
*
|
|
4
|
+
* Centralized constants for PWA 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,105 @@
|
|
|
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 } from 'react';
|
|
27
|
+
import { useDjangoPush } from '../hooks/useDjangoPush';
|
|
28
|
+
import type { PushNotificationOptions } from '../types';
|
|
29
|
+
|
|
30
|
+
interface DjangoPushContextValue {
|
|
31
|
+
// State
|
|
32
|
+
isSupported: boolean;
|
|
33
|
+
permission: NotificationPermission;
|
|
34
|
+
isSubscribed: boolean;
|
|
35
|
+
subscription: PushSubscription | null;
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
error: Error | null;
|
|
38
|
+
|
|
39
|
+
// Actions
|
|
40
|
+
subscribe: () => Promise<boolean>;
|
|
41
|
+
unsubscribe: () => Promise<boolean>;
|
|
42
|
+
sendTestPush: (message: { title: string; body: string; url?: string }) => Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DjangoPushContext = createContext<DjangoPushContextValue | undefined>(undefined);
|
|
46
|
+
|
|
47
|
+
interface DjangoPushProviderProps extends PushNotificationOptions {
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Auto-subscribe on mount if permission granted
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
autoSubscribe?: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callback when subscription created
|
|
58
|
+
*/
|
|
59
|
+
onSubscribed?: (subscription: PushSubscription) => void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Callback when subscription failed
|
|
63
|
+
*/
|
|
64
|
+
onSubscribeError?: (error: Error) => void;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Callback when unsubscribed
|
|
68
|
+
*/
|
|
69
|
+
onUnsubscribed?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Provider for Django push notifications
|
|
74
|
+
*/
|
|
75
|
+
export function DjangoPushProvider({
|
|
76
|
+
children,
|
|
77
|
+
vapidPublicKey,
|
|
78
|
+
autoSubscribe = false,
|
|
79
|
+
onSubscribed,
|
|
80
|
+
onSubscribeError,
|
|
81
|
+
onUnsubscribed,
|
|
82
|
+
}: DjangoPushProviderProps) {
|
|
83
|
+
const djangoPush = useDjangoPush({
|
|
84
|
+
vapidPublicKey,
|
|
85
|
+
autoSubscribe,
|
|
86
|
+
onSubscribed,
|
|
87
|
+
onSubscribeError,
|
|
88
|
+
onUnsubscribed,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return <DjangoPushContext.Provider value={djangoPush}>{children}</DjangoPushContext.Provider>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hook to access Django push context
|
|
96
|
+
*/
|
|
97
|
+
export function useDjangoPushContext(): DjangoPushContextValue {
|
|
98
|
+
const context = useContext(DjangoPushContext);
|
|
99
|
+
|
|
100
|
+
if (context === undefined) {
|
|
101
|
+
throw new Error('useDjangoPushContext must be used within DjangoPushProvider');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simplified PWA Install Context
|
|
5
|
+
*
|
|
6
|
+
* Minimal global state for PWA installation
|
|
7
|
+
* No tracking, no metrics, no engagement — just install state
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { createContext, useContext, ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
import { A2HSHint } from '../components/A2HSHint';
|
|
13
|
+
import type { InstallOutcome, PushNotificationOptions } from '../types';
|
|
14
|
+
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
|
15
|
+
import { PushProvider } from './PushContext';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Conditional PushProvider wrapper
|
|
19
|
+
* Only wraps children if push notifications are enabled
|
|
20
|
+
*/
|
|
21
|
+
function ConditionalPushProvider({
|
|
22
|
+
enabled,
|
|
23
|
+
children,
|
|
24
|
+
...config
|
|
25
|
+
}: PushNotificationOptions & { enabled: boolean; children: ReactNode }) {
|
|
26
|
+
if (!enabled) return <>{children}</>;
|
|
27
|
+
return <PushProvider {...config}>{children}</PushProvider>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PwaContextValue {
|
|
31
|
+
// Platform
|
|
32
|
+
isIOS: boolean;
|
|
33
|
+
isAndroid: boolean;
|
|
34
|
+
isSafari: boolean;
|
|
35
|
+
isChrome: boolean;
|
|
36
|
+
|
|
37
|
+
// State
|
|
38
|
+
isInstalled: boolean;
|
|
39
|
+
canPrompt: boolean;
|
|
40
|
+
|
|
41
|
+
// Actions
|
|
42
|
+
install: () => Promise<InstallOutcome>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
|
|
46
|
+
|
|
47
|
+
export interface PwaConfig {
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
showInstallHint?: boolean;
|
|
50
|
+
resetAfterDays?: number | null;
|
|
51
|
+
delayMs?: number;
|
|
52
|
+
logo?: string;
|
|
53
|
+
pushNotifications?: PushNotificationOptions & {
|
|
54
|
+
delayMs?: number;
|
|
55
|
+
resetAfterDays?: number;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function PwaProvider({ children, ...config }: PwaConfig & { children: React.ReactNode }) {
|
|
60
|
+
// If not enabled, acts as a simple pass-through
|
|
61
|
+
if (config.enabled === false) {
|
|
62
|
+
return <>{children}</>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const prompt = useInstallPrompt();
|
|
66
|
+
|
|
67
|
+
const value: PwaContextValue = {
|
|
68
|
+
isIOS: prompt.isIOS,
|
|
69
|
+
isAndroid: prompt.isAndroid,
|
|
70
|
+
isSafari: prompt.isSafari,
|
|
71
|
+
isChrome: prompt.isChrome,
|
|
72
|
+
isInstalled: prompt.isInstalled,
|
|
73
|
+
canPrompt: prompt.canPrompt,
|
|
74
|
+
install: prompt.promptInstall,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const showHint = config.showInstallHint !== false;
|
|
78
|
+
|
|
79
|
+
// ✅ Explicit composition tree (no magic wrapping)
|
|
80
|
+
// Structure:
|
|
81
|
+
// - PwaContext.Provider (PWA install state)
|
|
82
|
+
// - ConditionalPushProvider (optional push notifications)
|
|
83
|
+
// - children (user content)
|
|
84
|
+
// - A2HSHint (PWA install hint UI)
|
|
85
|
+
return (
|
|
86
|
+
<PwaContext.Provider value={value}>
|
|
87
|
+
<ConditionalPushProvider
|
|
88
|
+
enabled={!!config.pushNotifications}
|
|
89
|
+
vapidPublicKey={config.pushNotifications?.vapidPublicKey || ''}
|
|
90
|
+
subscribeEndpoint={config.pushNotifications?.subscribeEndpoint}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
{showHint && (
|
|
94
|
+
<A2HSHint
|
|
95
|
+
resetAfterDays={config.resetAfterDays}
|
|
96
|
+
delayMs={config.delayMs}
|
|
97
|
+
logo={config.logo}
|
|
98
|
+
pushNotifications={config.pushNotifications}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
</ConditionalPushProvider>
|
|
102
|
+
</PwaContext.Provider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Use install context
|
|
108
|
+
* Must be used within <PwaProvider>
|
|
109
|
+
*/
|
|
110
|
+
export function useInstall(): PwaContextValue {
|
|
111
|
+
const context = useContext(PwaContext);
|
|
112
|
+
|
|
113
|
+
if (context === undefined) {
|
|
114
|
+
throw new Error('useInstall must be used within <PwaProvider>');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return context;
|
|
118
|
+
}
|