@djangocfg/ui-nextjs 2.1.227 → 2.1.229
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 +55 -0
- package/package.json +12 -6
- package/src/components/sidebar.tsx +3 -1
- package/src/pwa/@docs/README.md +92 -0
- package/src/pwa/@docs/research/ios-android-install-flows.md +576 -0
- package/src/pwa/README.md +235 -0
- package/src/pwa/components/A2HSHint.tsx +236 -0
- package/src/pwa/components/DesktopGuide.tsx +234 -0
- package/src/pwa/components/IOSGuide.tsx +29 -0
- package/src/pwa/components/IOSGuideDrawer.tsx +103 -0
- package/src/pwa/components/IOSGuideModal.tsx +103 -0
- package/src/pwa/components/PWAPageResumeManager.tsx +33 -0
- package/src/pwa/context/InstallContext.tsx +102 -0
- package/src/pwa/hooks/useInstallPrompt.ts +168 -0
- package/src/pwa/hooks/useIsPWA.ts +116 -0
- package/src/pwa/hooks/usePWAPageResume.ts +163 -0
- package/src/pwa/index.ts +80 -0
- package/src/pwa/types/components.ts +95 -0
- package/src/pwa/types/config.ts +29 -0
- package/src/pwa/types/index.ts +26 -0
- package/src/pwa/types/install.ts +38 -0
- package/src/pwa/types/platform.ts +29 -0
- package/src/pwa/utils/localStorage.ts +181 -0
- package/src/pwa/utils/logger.ts +149 -0
- package/src/pwa/utils/platform.ts +151 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* iOS Installation Guide (Adaptive)
|
|
5
|
+
*
|
|
6
|
+
* Automatically uses:
|
|
7
|
+
* - Drawer on mobile (better swipe UX)
|
|
8
|
+
* - Dialog on desktop/tablet
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from 'react';
|
|
12
|
+
|
|
13
|
+
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
14
|
+
|
|
15
|
+
import { IOSGuideDrawer } from './IOSGuideDrawer';
|
|
16
|
+
import { IOSGuideModal } from './IOSGuideModal';
|
|
17
|
+
|
|
18
|
+
import type { IOSGuideModalProps } from '../types';
|
|
19
|
+
|
|
20
|
+
export function IOSGuide(props: IOSGuideModalProps) {
|
|
21
|
+
const isMobile = useIsMobile(); // Viewport < 768px
|
|
22
|
+
|
|
23
|
+
// Use drawer on mobile, dialog on desktop
|
|
24
|
+
if (isMobile) {
|
|
25
|
+
return <IOSGuideDrawer {...props} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return <IOSGuideModal {...props} />;
|
|
29
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { ArrowDown, ArrowUpRight, Check, CheckCircle, Share } from 'lucide-react';
|
|
11
|
+
import React, { useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
14
|
+
import {
|
|
15
|
+
Button, Card, CardContent, Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
17
|
+
|
|
18
|
+
import type { IOSGuideModalProps, InstallStep } from '../types';
|
|
19
|
+
|
|
20
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
21
|
+
return (
|
|
22
|
+
<Card className="border border-border">
|
|
23
|
+
<CardContent className="p-4">
|
|
24
|
+
<div className="flex items-start gap-3">
|
|
25
|
+
<div
|
|
26
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
27
|
+
style={{ width: '32px', height: '32px' }}
|
|
28
|
+
>
|
|
29
|
+
<span className="text-sm font-semibold">{step.number}</span>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div className="flex-1 min-w-0">
|
|
33
|
+
<div className="flex items-center gap-2 mb-1">
|
|
34
|
+
<step.icon className="w-5 h-5 text-primary" />
|
|
35
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
36
|
+
</div>
|
|
37
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</CardContent>
|
|
41
|
+
</Card>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function IOSGuideDrawer({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
46
|
+
const t = useAppT();
|
|
47
|
+
|
|
48
|
+
const labels = useMemo(() => ({
|
|
49
|
+
title: t('layouts.pwa.iosTitle'),
|
|
50
|
+
description: t('layouts.pwa.iosDescription'),
|
|
51
|
+
gotIt: t('layouts.pwa.gotIt'),
|
|
52
|
+
}), [t]);
|
|
53
|
+
|
|
54
|
+
const steps: InstallStep[] = useMemo(() => [
|
|
55
|
+
{
|
|
56
|
+
number: 1,
|
|
57
|
+
title: t('layouts.pwa.iosStep1Title'),
|
|
58
|
+
icon: ArrowUpRight,
|
|
59
|
+
description: t('layouts.pwa.iosStep1Desc'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
number: 2,
|
|
63
|
+
title: t('layouts.pwa.iosStep2Title'),
|
|
64
|
+
icon: ArrowDown,
|
|
65
|
+
description: t('layouts.pwa.iosStep2Desc'),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
number: 3,
|
|
69
|
+
title: t('layouts.pwa.iosStep3Title'),
|
|
70
|
+
icon: CheckCircle,
|
|
71
|
+
description: t('layouts.pwa.iosStep3Desc'),
|
|
72
|
+
},
|
|
73
|
+
], [t]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Drawer open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
77
|
+
<DrawerContent>
|
|
78
|
+
<DrawerHeader className="text-left">
|
|
79
|
+
<DrawerTitle className="flex items-center gap-2">
|
|
80
|
+
<Share className="w-5 h-5 text-primary" />
|
|
81
|
+
{labels.title}
|
|
82
|
+
</DrawerTitle>
|
|
83
|
+
<DrawerDescription className="text-left">
|
|
84
|
+
{labels.description}
|
|
85
|
+
</DrawerDescription>
|
|
86
|
+
</DrawerHeader>
|
|
87
|
+
|
|
88
|
+
<div className="space-y-3 p-4">
|
|
89
|
+
{steps.map((step) => (
|
|
90
|
+
<StepCard key={step.number} step={step} />
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="p-4 pt-0">
|
|
95
|
+
<Button onClick={onDismiss} variant="default" className="w-full">
|
|
96
|
+
<Check className="w-4 h-4 mr-2" />
|
|
97
|
+
{labels.gotIt}
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
</DrawerContent>
|
|
101
|
+
</Drawer>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { ArrowDown, ArrowUpRight, Check, CheckCircle, Share } from 'lucide-react';
|
|
10
|
+
import React, { useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
13
|
+
import {
|
|
14
|
+
Button, Card, CardContent, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
|
|
15
|
+
DialogTitle
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
17
|
+
|
|
18
|
+
import type { IOSGuideModalProps, InstallStep } from '../types';
|
|
19
|
+
|
|
20
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
21
|
+
return (
|
|
22
|
+
<Card className="border border-border">
|
|
23
|
+
<CardContent className="p-4">
|
|
24
|
+
<div className="flex items-start gap-3">
|
|
25
|
+
<div
|
|
26
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
27
|
+
style={{ width: '32px', height: '32px' }}
|
|
28
|
+
>
|
|
29
|
+
<span className="text-sm font-semibold">{step.number}</span>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div className="flex-1 min-w-0">
|
|
33
|
+
<div className="flex items-center gap-2 mb-1">
|
|
34
|
+
<step.icon className="w-5 h-5 text-primary" />
|
|
35
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
36
|
+
</div>
|
|
37
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</CardContent>
|
|
41
|
+
</Card>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function IOSGuideModal({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
46
|
+
const t = useAppT();
|
|
47
|
+
|
|
48
|
+
const labels = useMemo(() => ({
|
|
49
|
+
title: t('layouts.pwa.iosTitle'),
|
|
50
|
+
description: t('layouts.pwa.iosDescription'),
|
|
51
|
+
gotIt: t('layouts.pwa.gotIt'),
|
|
52
|
+
}), [t]);
|
|
53
|
+
|
|
54
|
+
const steps: InstallStep[] = useMemo(() => [
|
|
55
|
+
{
|
|
56
|
+
number: 1,
|
|
57
|
+
title: t('layouts.pwa.iosStep1Title'),
|
|
58
|
+
icon: ArrowUpRight,
|
|
59
|
+
description: t('layouts.pwa.iosStep1Desc'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
number: 2,
|
|
63
|
+
title: t('layouts.pwa.iosStep2Title'),
|
|
64
|
+
icon: ArrowDown,
|
|
65
|
+
description: t('layouts.pwa.iosStep2Desc'),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
number: 3,
|
|
69
|
+
title: t('layouts.pwa.iosStep3Title'),
|
|
70
|
+
icon: CheckCircle,
|
|
71
|
+
description: t('layouts.pwa.iosStep3Desc'),
|
|
72
|
+
},
|
|
73
|
+
], [t]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
77
|
+
<DialogContent className="sm:max-w-md">
|
|
78
|
+
<DialogHeader className="text-left">
|
|
79
|
+
<DialogTitle className="flex items-center gap-2">
|
|
80
|
+
<Share className="w-5 h-5 text-primary" />
|
|
81
|
+
{labels.title}
|
|
82
|
+
</DialogTitle>
|
|
83
|
+
<DialogDescription className="text-left">
|
|
84
|
+
{labels.description}
|
|
85
|
+
</DialogDescription>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
|
|
88
|
+
<div className="space-y-3 py-4">
|
|
89
|
+
{steps.map((step) => (
|
|
90
|
+
<StepCard key={step.number} step={step} />
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<DialogFooter>
|
|
95
|
+
<Button onClick={onDismiss} variant="default" className="w-full">
|
|
96
|
+
<Check className="w-4 h-4 mr-2" />
|
|
97
|
+
{labels.gotIt}
|
|
98
|
+
</Button>
|
|
99
|
+
</DialogFooter>
|
|
100
|
+
</DialogContent>
|
|
101
|
+
</Dialog>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PWA Page Resume Manager
|
|
5
|
+
*
|
|
6
|
+
* Invisible component that manages page resume functionality for PWA.
|
|
7
|
+
* Add this component to your app layout to enable page resume on PWA launch.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // In your layout
|
|
12
|
+
* <PWAPageResumeManager enabled={true} />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { usePWAPageResume } from '../hooks/usePWAPageResume';
|
|
17
|
+
|
|
18
|
+
interface PWAPageResumeManagerProps {
|
|
19
|
+
/**
|
|
20
|
+
* Enable page resume feature
|
|
21
|
+
* @default true
|
|
22
|
+
*/
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Component that manages PWA page resume functionality
|
|
28
|
+
* Renders nothing, just manages state and navigation
|
|
29
|
+
*/
|
|
30
|
+
export function PWAPageResumeManager({ enabled = true }: PWAPageResumeManagerProps) {
|
|
31
|
+
usePWAPageResume({ enabled });
|
|
32
|
+
return null; // Renders nothing, just manages state
|
|
33
|
+
}
|
|
@@ -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, ReactNode, useContext } from 'react';
|
|
11
|
+
|
|
12
|
+
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
|
13
|
+
|
|
14
|
+
import type { InstallOutcome } from '../types';
|
|
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,168 @@
|
|
|
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-core for platform detection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { useBrowserDetect, useDeviceDetect } from '../../hooks';
|
|
13
|
+
|
|
14
|
+
import { markAppInstalled } from '../utils/localStorage';
|
|
15
|
+
import { pwaLogger } from '../utils/logger';
|
|
16
|
+
import { isStandalone, onDisplayModeChange } from '../utils/platform';
|
|
17
|
+
|
|
18
|
+
import type { BeforeInstallPromptEvent, InstallPromptState, InstallOutcome } from '../types';
|
|
19
|
+
export function useInstallPrompt() {
|
|
20
|
+
const browser = useBrowserDetect();
|
|
21
|
+
const device = useDeviceDetect();
|
|
22
|
+
|
|
23
|
+
const [state, setState] = useState<InstallPromptState>(() => {
|
|
24
|
+
if (typeof window === 'undefined') {
|
|
25
|
+
return {
|
|
26
|
+
isIOS: false,
|
|
27
|
+
isAndroid: false,
|
|
28
|
+
isSafari: false,
|
|
29
|
+
isChrome: false,
|
|
30
|
+
isInstalled: false,
|
|
31
|
+
canPrompt: false,
|
|
32
|
+
deferredPrompt: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Real Safari = Safari && NOT Chromium (avoid Arc, Brave on iOS)
|
|
37
|
+
const isSafari = browser.isSafari && !browser.isChromium;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
isIOS: device.isIOS,
|
|
41
|
+
isAndroid: device.isAndroid,
|
|
42
|
+
isSafari,
|
|
43
|
+
isChrome: browser.isChrome || browser.isChromium,
|
|
44
|
+
isInstalled: isStandalone(),
|
|
45
|
+
canPrompt: false,
|
|
46
|
+
deferredPrompt: null,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Update state when platform info changes
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const isSafari = browser.isSafari && !browser.isChromium;
|
|
53
|
+
|
|
54
|
+
setState((prev) => ({
|
|
55
|
+
...prev,
|
|
56
|
+
isIOS: device.isIOS,
|
|
57
|
+
isAndroid: device.isAndroid,
|
|
58
|
+
isSafari,
|
|
59
|
+
isChrome: browser.isChrome || browser.isChromium,
|
|
60
|
+
isInstalled: isStandalone(),
|
|
61
|
+
}));
|
|
62
|
+
}, [browser, device]);
|
|
63
|
+
|
|
64
|
+
// Listen for beforeinstallprompt event (Android Chrome only)
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (typeof window === 'undefined') return;
|
|
67
|
+
|
|
68
|
+
const handleBeforeInstallPrompt = (e: Event) => {
|
|
69
|
+
// Prevent the default browser install prompt
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
|
|
72
|
+
const event = e as BeforeInstallPromptEvent;
|
|
73
|
+
|
|
74
|
+
setState((prev) => ({
|
|
75
|
+
...prev,
|
|
76
|
+
canPrompt: true,
|
|
77
|
+
deferredPrompt: event,
|
|
78
|
+
}));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
85
|
+
};
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
// Listen for appinstalled event (Android Chrome)
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (typeof window === 'undefined') return;
|
|
91
|
+
|
|
92
|
+
const handleAppInstalled = () => {
|
|
93
|
+
setState((prev) => ({
|
|
94
|
+
...prev,
|
|
95
|
+
canPrompt: false,
|
|
96
|
+
deferredPrompt: null,
|
|
97
|
+
isInstalled: true,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
// Mark as installed in localStorage
|
|
101
|
+
markAppInstalled();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
108
|
+
};
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// Check for display-mode changes (user adds to home screen)
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const cleanup = onDisplayModeChange((isStandaloneMode) => {
|
|
114
|
+
if (isStandaloneMode) {
|
|
115
|
+
setState((prev) => ({
|
|
116
|
+
...prev,
|
|
117
|
+
isInstalled: true,
|
|
118
|
+
canPrompt: false,
|
|
119
|
+
deferredPrompt: null,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
markAppInstalled();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return cleanup;
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Trigger Android native install prompt
|
|
131
|
+
*/
|
|
132
|
+
const promptInstall = async (): Promise<InstallOutcome> => {
|
|
133
|
+
if (!state.deferredPrompt) {
|
|
134
|
+
pwaLogger.warn('[PWA Install] No deferred prompt available');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Show the native prompt
|
|
140
|
+
await state.deferredPrompt.prompt();
|
|
141
|
+
|
|
142
|
+
// Wait for user response
|
|
143
|
+
const { outcome } = await state.deferredPrompt.userChoice;
|
|
144
|
+
|
|
145
|
+
pwaLogger.info('[PWA Install] User choice:', outcome);
|
|
146
|
+
|
|
147
|
+
// Clear the deferred prompt
|
|
148
|
+
setState((prev) => ({
|
|
149
|
+
...prev,
|
|
150
|
+
deferredPrompt: null,
|
|
151
|
+
canPrompt: false,
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
return outcome;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
pwaLogger.error('[PWA Install] Error showing install prompt:', error);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...state,
|
|
163
|
+
promptInstall,
|
|
164
|
+
// Expose full browser info
|
|
165
|
+
browser,
|
|
166
|
+
device,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { useEffect, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
import { isStandalone, isStandaloneReliable, onDisplayModeChange } from '../utils/platform';
|
|
22
|
+
|
|
23
|
+
const CACHE_KEY = 'pwa_is_standalone';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for useIsPWA hook
|
|
27
|
+
*/
|
|
28
|
+
export interface UseIsPWAOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Use reliable check with additional validation for desktop browsers
|
|
31
|
+
* This prevents false positives on Safari macOS "Add to Dock"
|
|
32
|
+
* @default false
|
|
33
|
+
*/
|
|
34
|
+
reliable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hook to detect if app is running as PWA (standalone mode)
|
|
39
|
+
*
|
|
40
|
+
* @param options - Configuration options
|
|
41
|
+
* @returns true if app is running as PWA
|
|
42
|
+
*/
|
|
43
|
+
export function useIsPWA(options?: UseIsPWAOptions): boolean {
|
|
44
|
+
const checkFunction = options?.reliable ? isStandaloneReliable : isStandalone;
|
|
45
|
+
|
|
46
|
+
const [isPWA, setIsPWA] = useState<boolean>(() => {
|
|
47
|
+
// Try to restore from cache for fast initialization
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
try {
|
|
50
|
+
const cached = sessionStorage.getItem(CACHE_KEY);
|
|
51
|
+
if (cached !== null) {
|
|
52
|
+
return cached === 'true';
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore sessionStorage errors (e.g., in incognito mode)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Initial check
|
|
60
|
+
return checkFunction();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Check after mount (may differ from SSR)
|
|
65
|
+
const isStandaloneMode = checkFunction();
|
|
66
|
+
setIsPWA(isStandaloneMode);
|
|
67
|
+
|
|
68
|
+
// Update cache
|
|
69
|
+
if (typeof window !== 'undefined') {
|
|
70
|
+
try {
|
|
71
|
+
sessionStorage.setItem(CACHE_KEY, String(isStandaloneMode));
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore sessionStorage errors
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Listen for display-mode changes
|
|
78
|
+
const cleanup = onDisplayModeChange((newValue) => {
|
|
79
|
+
setIsPWA(newValue);
|
|
80
|
+
|
|
81
|
+
// Update cache
|
|
82
|
+
if (typeof window !== 'undefined') {
|
|
83
|
+
try {
|
|
84
|
+
sessionStorage.setItem(CACHE_KEY, String(newValue));
|
|
85
|
+
} catch {
|
|
86
|
+
// Ignore sessionStorage errors
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return cleanup;
|
|
92
|
+
}, [checkFunction]);
|
|
93
|
+
|
|
94
|
+
return isPWA;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear isPWA cache
|
|
99
|
+
*
|
|
100
|
+
* Useful for testing or when you want to force a re-check
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* import { clearIsPWACache } from '@djangocfg/layouts/snippets';
|
|
105
|
+
* clearIsPWACache();
|
|
106
|
+
* window.location.reload();
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function clearIsPWACache(): void {
|
|
110
|
+
if (typeof window === 'undefined') return;
|
|
111
|
+
try {
|
|
112
|
+
sessionStorage.removeItem(CACHE_KEY);
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore sessionStorage errors
|
|
115
|
+
}
|
|
116
|
+
}
|