@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,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,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 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 type { InstallOutcome } from '../types';
|
|
13
|
+
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
|
14
|
+
|
|
15
|
+
export interface PwaContextValue {
|
|
16
|
+
// Platform
|
|
17
|
+
isIOS: boolean;
|
|
18
|
+
isAndroid: boolean;
|
|
19
|
+
isDesktop: boolean;
|
|
20
|
+
|
|
21
|
+
// Browsers
|
|
22
|
+
isSafari: boolean;
|
|
23
|
+
isChrome: boolean;
|
|
24
|
+
isFirefox: boolean;
|
|
25
|
+
isEdge: boolean;
|
|
26
|
+
isOpera: boolean;
|
|
27
|
+
isBrave: boolean;
|
|
28
|
+
isArc: boolean;
|
|
29
|
+
isVivaldi: boolean;
|
|
30
|
+
isYandex: boolean;
|
|
31
|
+
isSamsungBrowser: boolean;
|
|
32
|
+
isUCBrowser: boolean;
|
|
33
|
+
isChromium: boolean; // Any Chromium-based browser
|
|
34
|
+
browserName: string;
|
|
35
|
+
|
|
36
|
+
// State
|
|
37
|
+
isInstalled: boolean;
|
|
38
|
+
canPrompt: boolean;
|
|
39
|
+
|
|
40
|
+
// Actions
|
|
41
|
+
install: () => Promise<InstallOutcome>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
|
|
45
|
+
|
|
46
|
+
export interface PwaConfig {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function PwaProvider({ children, ...config }: PwaConfig & { children: ReactNode }) {
|
|
51
|
+
// If not enabled, acts as a simple pass-through
|
|
52
|
+
if (config.enabled === false) {
|
|
53
|
+
return <>{children}</>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const prompt = useInstallPrompt();
|
|
57
|
+
|
|
58
|
+
const value: PwaContextValue = {
|
|
59
|
+
// Platform
|
|
60
|
+
isIOS: prompt.isIOS,
|
|
61
|
+
isAndroid: prompt.isAndroid,
|
|
62
|
+
isDesktop: !prompt.isIOS && !prompt.isAndroid,
|
|
63
|
+
|
|
64
|
+
// Browsers (from useBrowserDetect)
|
|
65
|
+
isSafari: prompt.browser.isSafari && !prompt.browser.isChromium, // Real Safari only
|
|
66
|
+
isChrome: prompt.browser.isChrome,
|
|
67
|
+
isFirefox: prompt.browser.isFirefox,
|
|
68
|
+
isEdge: prompt.browser.isEdge,
|
|
69
|
+
isOpera: prompt.browser.isOpera,
|
|
70
|
+
isBrave: prompt.browser.isBrave,
|
|
71
|
+
isArc: prompt.browser.isArc,
|
|
72
|
+
isVivaldi: prompt.browser.isVivaldi,
|
|
73
|
+
isYandex: prompt.browser.isYandex,
|
|
74
|
+
isSamsungBrowser: prompt.browser.isSamsungBrowser,
|
|
75
|
+
isUCBrowser: prompt.browser.isUCBrowser,
|
|
76
|
+
isChromium: prompt.browser.isChromium,
|
|
77
|
+
browserName: prompt.browser.browserName,
|
|
78
|
+
|
|
79
|
+
// State
|
|
80
|
+
isInstalled: prompt.isInstalled,
|
|
81
|
+
canPrompt: prompt.canPrompt,
|
|
82
|
+
|
|
83
|
+
// Actions
|
|
84
|
+
install: prompt.promptInstall,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return <PwaContext.Provider value={value}>{children}</PwaContext.Provider>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Use install context
|
|
92
|
+
* Must be used within <PwaProvider>
|
|
93
|
+
*/
|
|
94
|
+
export function useInstall(): PwaContextValue {
|
|
95
|
+
const context = useContext(PwaContext);
|
|
96
|
+
|
|
97
|
+
if (context === undefined) {
|
|
98
|
+
throw new Error('useInstall must be used within <PwaProvider>');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return context;
|
|
102
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PWA Install Prompt Hook
|
|
5
|
+
*
|
|
6
|
+
* Manages beforeinstallprompt event and installation state
|
|
7
|
+
* Uses existing hooks from @djangocfg/ui-nextjs for platform detection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
import { useBrowserDetect, useDeviceDetect } from '@djangocfg/ui-nextjs';
|
|
12
|
+
|
|
13
|
+
import type { BeforeInstallPromptEvent, InstallPromptState, InstallOutcome } from '../types';
|
|
14
|
+
import { markAppInstalled } from '../utils/localStorage';
|
|
15
|
+
import { isStandalone, onDisplayModeChange } from '../utils/platform';
|
|
16
|
+
import { pwaLogger } from '../utils/logger';
|
|
17
|
+
|
|
18
|
+
export function useInstallPrompt() {
|
|
19
|
+
const browser = useBrowserDetect();
|
|
20
|
+
const device = useDeviceDetect();
|
|
21
|
+
|
|
22
|
+
const [state, setState] = useState<InstallPromptState>(() => {
|
|
23
|
+
if (typeof window === 'undefined') {
|
|
24
|
+
return {
|
|
25
|
+
isIOS: false,
|
|
26
|
+
isAndroid: false,
|
|
27
|
+
isSafari: false,
|
|
28
|
+
isChrome: false,
|
|
29
|
+
isInstalled: false,
|
|
30
|
+
canPrompt: false,
|
|
31
|
+
deferredPrompt: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Real Safari = Safari && NOT Chromium (avoid Arc, Brave on iOS)
|
|
36
|
+
const isSafari = browser.isSafari && !browser.isChromium;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
isIOS: device.isIOS,
|
|
40
|
+
isAndroid: device.isAndroid,
|
|
41
|
+
isSafari,
|
|
42
|
+
isChrome: browser.isChrome || browser.isChromium,
|
|
43
|
+
isInstalled: isStandalone(),
|
|
44
|
+
canPrompt: false,
|
|
45
|
+
deferredPrompt: null,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Update state when platform info changes
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const isSafari = browser.isSafari && !browser.isChromium;
|
|
52
|
+
|
|
53
|
+
setState((prev) => ({
|
|
54
|
+
...prev,
|
|
55
|
+
isIOS: device.isIOS,
|
|
56
|
+
isAndroid: device.isAndroid,
|
|
57
|
+
isSafari,
|
|
58
|
+
isChrome: browser.isChrome || browser.isChromium,
|
|
59
|
+
isInstalled: isStandalone(),
|
|
60
|
+
}));
|
|
61
|
+
}, [browser, device]);
|
|
62
|
+
|
|
63
|
+
// Listen for beforeinstallprompt event (Android Chrome only)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (typeof window === 'undefined') return;
|
|
66
|
+
|
|
67
|
+
const handleBeforeInstallPrompt = (e: Event) => {
|
|
68
|
+
// Prevent the default browser install prompt
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
|
|
71
|
+
const event = e as BeforeInstallPromptEvent;
|
|
72
|
+
|
|
73
|
+
setState((prev) => ({
|
|
74
|
+
...prev,
|
|
75
|
+
canPrompt: true,
|
|
76
|
+
deferredPrompt: event,
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Listen for appinstalled event (Android Chrome)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (typeof window === 'undefined') return;
|
|
90
|
+
|
|
91
|
+
const handleAppInstalled = () => {
|
|
92
|
+
setState((prev) => ({
|
|
93
|
+
...prev,
|
|
94
|
+
canPrompt: false,
|
|
95
|
+
deferredPrompt: null,
|
|
96
|
+
isInstalled: true,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
// Mark as installed in localStorage
|
|
100
|
+
markAppInstalled();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// Check for display-mode changes (user adds to home screen)
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const cleanup = onDisplayModeChange((isStandaloneMode) => {
|
|
113
|
+
if (isStandaloneMode) {
|
|
114
|
+
setState((prev) => ({
|
|
115
|
+
...prev,
|
|
116
|
+
isInstalled: true,
|
|
117
|
+
canPrompt: false,
|
|
118
|
+
deferredPrompt: null,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
markAppInstalled();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return cleanup;
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Trigger Android native install prompt
|
|
130
|
+
*/
|
|
131
|
+
const promptInstall = async (): Promise<InstallOutcome> => {
|
|
132
|
+
if (!state.deferredPrompt) {
|
|
133
|
+
pwaLogger.warn('[PWA Install] No deferred prompt available');
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Show the native prompt
|
|
139
|
+
await state.deferredPrompt.prompt();
|
|
140
|
+
|
|
141
|
+
// Wait for user response
|
|
142
|
+
const { outcome } = await state.deferredPrompt.userChoice;
|
|
143
|
+
|
|
144
|
+
pwaLogger.info('[PWA Install] User choice:', outcome);
|
|
145
|
+
|
|
146
|
+
// Clear the deferred prompt
|
|
147
|
+
setState((prev) => ({
|
|
148
|
+
...prev,
|
|
149
|
+
deferredPrompt: null,
|
|
150
|
+
canPrompt: false,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
return outcome;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
pwaLogger.error('[PWA Install] Error showing install prompt:', error);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...state,
|
|
162
|
+
promptInstall,
|
|
163
|
+
// Expose full browser info
|
|
164
|
+
browser,
|
|
165
|
+
device,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook to detect if app is running as PWA
|
|
5
|
+
*
|
|
6
|
+
* Checks if the app is running in standalone mode (added to home screen).
|
|
7
|
+
* Results are cached in sessionStorage for fast initialization across page loads.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // Basic usage (standard check)
|
|
12
|
+
* const isPWA = useIsPWA();
|
|
13
|
+
*
|
|
14
|
+
* // Reliable check (with manifest validation for desktop)
|
|
15
|
+
* const isPWA = useIsPWA({ reliable: true });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState, useEffect } from 'react';
|
|
20
|
+
import { isStandalone, isStandaloneReliable, onDisplayModeChange } from '../utils/platform';
|
|
21
|
+
|
|
22
|
+
const CACHE_KEY = 'pwa_is_standalone';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Options for useIsPWA hook
|
|
26
|
+
*/
|
|
27
|
+
export interface UseIsPWAOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Use reliable check with additional validation for desktop browsers
|
|
30
|
+
* This prevents false positives on Safari macOS "Add to Dock"
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
reliable?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hook to detect if app is running as PWA (standalone mode)
|
|
38
|
+
*
|
|
39
|
+
* @param options - Configuration options
|
|
40
|
+
* @returns true if app is running as PWA
|
|
41
|
+
*/
|
|
42
|
+
export function useIsPWA(options?: UseIsPWAOptions): boolean {
|
|
43
|
+
const checkFunction = options?.reliable ? isStandaloneReliable : isStandalone;
|
|
44
|
+
|
|
45
|
+
const [isPWA, setIsPWA] = useState<boolean>(() => {
|
|
46
|
+
// Try to restore from cache for fast initialization
|
|
47
|
+
if (typeof window !== 'undefined') {
|
|
48
|
+
try {
|
|
49
|
+
const cached = sessionStorage.getItem(CACHE_KEY);
|
|
50
|
+
if (cached !== null) {
|
|
51
|
+
return cached === 'true';
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignore sessionStorage errors (e.g., in incognito mode)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Initial check
|
|
59
|
+
return checkFunction();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
// Check after mount (may differ from SSR)
|
|
64
|
+
const isStandaloneMode = checkFunction();
|
|
65
|
+
setIsPWA(isStandaloneMode);
|
|
66
|
+
|
|
67
|
+
// Update cache
|
|
68
|
+
if (typeof window !== 'undefined') {
|
|
69
|
+
try {
|
|
70
|
+
sessionStorage.setItem(CACHE_KEY, String(isStandaloneMode));
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore sessionStorage errors
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Listen for display-mode changes
|
|
77
|
+
const cleanup = onDisplayModeChange((newValue) => {
|
|
78
|
+
setIsPWA(newValue);
|
|
79
|
+
|
|
80
|
+
// Update cache
|
|
81
|
+
if (typeof window !== 'undefined') {
|
|
82
|
+
try {
|
|
83
|
+
sessionStorage.setItem(CACHE_KEY, String(newValue));
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore sessionStorage errors
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return cleanup;
|
|
91
|
+
}, [checkFunction]);
|
|
92
|
+
|
|
93
|
+
return isPWA;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Clear isPWA cache
|
|
98
|
+
*
|
|
99
|
+
* Useful for testing or when you want to force a re-check
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* import { clearIsPWACache } from '@djangocfg/layouts/snippets';
|
|
104
|
+
* clearIsPWACache();
|
|
105
|
+
* window.location.reload();
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function clearIsPWACache(): void {
|
|
109
|
+
if (typeof window === 'undefined') return;
|
|
110
|
+
try {
|
|
111
|
+
sessionStorage.removeItem(CACHE_KEY);
|
|
112
|
+
} catch {
|
|
113
|
+
// Ignore sessionStorage errors
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PWA Install Snippet
|
|
3
|
+
*
|
|
4
|
+
* Simplified PWA installation for web apps
|
|
5
|
+
* Handles Add to Home Screen (A2HS) for iOS Safari and Android Chrome
|
|
6
|
+
*
|
|
7
|
+
* @example Basic usage
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { PwaProvider, A2HSHint } from '@/snippets/PWAInstall';
|
|
10
|
+
*
|
|
11
|
+
* export default function Layout({ children }) {
|
|
12
|
+
* return (
|
|
13
|
+
* <PwaProvider>
|
|
14
|
+
* {children}
|
|
15
|
+
* <A2HSHint resetAfterDays={3} />
|
|
16
|
+
* </PwaProvider>
|
|
17
|
+
* );
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Main API
|
|
23
|
+
export { PwaProvider, useInstall } from './context/InstallContext';
|
|
24
|
+
export { A2HSHint } from './components/A2HSHint';
|
|
25
|
+
export { IOSGuide } from './components/IOSGuide';
|
|
26
|
+
export { DesktopGuide } from './components/DesktopGuide';
|
|
27
|
+
|
|
28
|
+
// Hooks
|
|
29
|
+
export { useIsPWA, clearIsPWACache, type UseIsPWAOptions } from './hooks/useIsPWA';
|
|
30
|
+
|
|
31
|
+
// Utilities
|
|
32
|
+
export {
|
|
33
|
+
isStandalone,
|
|
34
|
+
isStandaloneReliable,
|
|
35
|
+
isMobileDevice,
|
|
36
|
+
hasValidManifest,
|
|
37
|
+
getDisplayMode,
|
|
38
|
+
onDisplayModeChange,
|
|
39
|
+
} from './utils/platform';
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
pwaLogger,
|
|
43
|
+
enablePWADebug,
|
|
44
|
+
disablePWADebug,
|
|
45
|
+
isPWADebugEnabled,
|
|
46
|
+
} from './utils/logger';
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
STORAGE_KEYS,
|
|
50
|
+
markA2HSDismissed,
|
|
51
|
+
isA2HSDismissedRecently,
|
|
52
|
+
clearAllPWAInstallData,
|
|
53
|
+
} from './utils/localStorage';
|
|
54
|
+
|
|
55
|
+
// Types - Configuration
|
|
56
|
+
export type { PwaInstallConfig } from './types';
|
|
57
|
+
|
|
58
|
+
// Types - Platform
|
|
59
|
+
export type { PlatformInfo } from './types';
|
|
60
|
+
|
|
61
|
+
// Types - Install
|
|
62
|
+
export type {
|
|
63
|
+
InstallPromptState,
|
|
64
|
+
BeforeInstallPromptEvent,
|
|
65
|
+
InstallOutcome,
|
|
66
|
+
IOSGuideState,
|
|
67
|
+
} from './types';
|
|
68
|
+
|
|
69
|
+
// Types - Components
|
|
70
|
+
export type {
|
|
71
|
+
InstallContextType,
|
|
72
|
+
InstallManagerProps,
|
|
73
|
+
AndroidInstallButtonProps,
|
|
74
|
+
IOSGuideModalProps,
|
|
75
|
+
InstallStep,
|
|
76
|
+
} from './types';
|