@djangocfg/ui-nextjs 2.1.227 → 2.1.228
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,235 @@
|
|
|
1
|
+
# PWA Install
|
|
2
|
+
|
|
3
|
+
Progressive Web App installation snippet for web applications.
|
|
4
|
+
|
|
5
|
+
## Responsibility
|
|
6
|
+
|
|
7
|
+
**Installation of PWA on user's device** (Add to Home Screen)
|
|
8
|
+
|
|
9
|
+
- iOS Safari: Visual guide for manual installation
|
|
10
|
+
- Android Chrome: Native install prompt
|
|
11
|
+
- Unified UX across platforms
|
|
12
|
+
- Smart dismissal with auto-reset
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
// app/layout.tsx
|
|
18
|
+
import { PwaProvider, A2HSHint } from '@/snippets/PWAInstall';
|
|
19
|
+
|
|
20
|
+
export default function RootLayout({ children }) {
|
|
21
|
+
return (
|
|
22
|
+
<html>
|
|
23
|
+
<body>
|
|
24
|
+
<PwaProvider>
|
|
25
|
+
{children}
|
|
26
|
+
<A2HSHint resetAfterDays={3} />
|
|
27
|
+
</PwaProvider>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What You Get
|
|
35
|
+
|
|
36
|
+
- **Unified UX** → Same hint position (bottom) for both iOS & Android
|
|
37
|
+
- **iOS Safari** → Click hint → Opens visual step-by-step guide
|
|
38
|
+
- **Android Chrome** → Click hint → Native install prompt
|
|
39
|
+
- **Visual guide** → Adaptive (drawer on mobile, modal on desktop)
|
|
40
|
+
- **Zero config** → Works out of the box
|
|
41
|
+
- **Smart reset** → Re-appears after 3 days (user gets second chance)
|
|
42
|
+
- **No spam** → Dismissible, respects user choice
|
|
43
|
+
|
|
44
|
+
## API
|
|
45
|
+
|
|
46
|
+
### `<PwaProvider>`
|
|
47
|
+
|
|
48
|
+
Wrap your app once:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
<PwaProvider>
|
|
52
|
+
{children}
|
|
53
|
+
</PwaProvider>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `<A2HSHint />`
|
|
57
|
+
|
|
58
|
+
Unified install hint for iOS & Android (auto-shows, dismissible):
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
<A2HSHint
|
|
62
|
+
resetAfterDays={3} // Default: 3 days (set to null for never)
|
|
63
|
+
delayMs={3000} // Default: 3 seconds
|
|
64
|
+
forceShow={isDevelopment} // Show on ANY browser (for dev testing)
|
|
65
|
+
logo="/logo192.png" // App logo URL (fallback: Share icon)
|
|
66
|
+
/>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Behavior (iOS Safari):**
|
|
70
|
+
- Shows after 3 seconds
|
|
71
|
+
- Text: "Keep terminal with you → Tap to learn how"
|
|
72
|
+
- Click → Opens visual guide (adaptive: drawer on mobile, modal on desktop)
|
|
73
|
+
- Dismissible (saved to localStorage)
|
|
74
|
+
- Auto-resets after 3 days
|
|
75
|
+
|
|
76
|
+
**Behavior (Android Chrome):**
|
|
77
|
+
- Shows after 3 seconds
|
|
78
|
+
- Text: "Install Cmdop → Tap to install"
|
|
79
|
+
- Click → Triggers native install prompt
|
|
80
|
+
- Dismissible (saved to localStorage)
|
|
81
|
+
- Auto-resets after 3 days
|
|
82
|
+
|
|
83
|
+
### `useInstall()` hook
|
|
84
|
+
|
|
85
|
+
Access install state anywhere:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { useInstall } from '@/snippets/PWAInstall';
|
|
89
|
+
|
|
90
|
+
function Header() {
|
|
91
|
+
const { isIOS, isInstalled, canPrompt, install } = useInstall();
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<header>
|
|
95
|
+
{canPrompt && <button onClick={install}>Install</button>}
|
|
96
|
+
{isInstalled && <span>✓ Installed</span>}
|
|
97
|
+
</header>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Returns:**
|
|
103
|
+
```ts
|
|
104
|
+
{
|
|
105
|
+
isIOS: boolean;
|
|
106
|
+
isAndroid: boolean;
|
|
107
|
+
isSafari: boolean;
|
|
108
|
+
isChrome: boolean;
|
|
109
|
+
isInstalled: boolean;
|
|
110
|
+
canPrompt: boolean;
|
|
111
|
+
install: () => Promise<'accepted' | 'dismissed' | null>;
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `usePWAPageResume()` hook
|
|
116
|
+
|
|
117
|
+
Resume last page when opening PWA:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { usePWAPageResume } from '@/snippets/PWAInstall';
|
|
121
|
+
|
|
122
|
+
function AppManager() {
|
|
123
|
+
const { isPWA } = usePWAPageResume({ enabled: true });
|
|
124
|
+
|
|
125
|
+
return null; // Just manages state
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Behavior:**
|
|
130
|
+
- Saves current pathname on every navigation (always, not just in PWA mode)
|
|
131
|
+
- Restores last page on PWA launch (only once per session)
|
|
132
|
+
- Auto-excludes auth, error, and callback pages
|
|
133
|
+
- Uses TTL (24 hours) to auto-expire saved page
|
|
134
|
+
|
|
135
|
+
### `<PWAPageResumeManager />`
|
|
136
|
+
|
|
137
|
+
Component wrapper for `usePWAPageResume`:
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
import { PWAPageResumeManager } from '@/snippets/PWAInstall';
|
|
141
|
+
|
|
142
|
+
// In your layout
|
|
143
|
+
<PWAPageResumeManager enabled={true} />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Page Resume via BaseApp
|
|
147
|
+
|
|
148
|
+
The easiest way to enable page resume is through BaseApp config:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<BaseApp
|
|
152
|
+
pwaInstall={{
|
|
153
|
+
enabled: true,
|
|
154
|
+
resumeLastPage: true, // Enable page resume
|
|
155
|
+
logo: '/logo192.png',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</BaseApp>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Usage with Push Notifications
|
|
163
|
+
|
|
164
|
+
Use together with the PushNotifications snippet:
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
import { PwaProvider, A2HSHint } from '@/snippets/PWAInstall';
|
|
168
|
+
import { DjangoPushProvider, PushPrompt } from '@/snippets/PushNotifications';
|
|
169
|
+
|
|
170
|
+
export default function Layout({ children }) {
|
|
171
|
+
return (
|
|
172
|
+
<PwaProvider>
|
|
173
|
+
<DjangoPushProvider vapidPublicKey={VAPID_KEY}>
|
|
174
|
+
{children}
|
|
175
|
+
|
|
176
|
+
{/* PWA Install hint */}
|
|
177
|
+
<A2HSHint resetAfterDays={3} />
|
|
178
|
+
|
|
179
|
+
{/* Push notification prompt (after PWA install) */}
|
|
180
|
+
<PushPrompt
|
|
181
|
+
requirePWA={true}
|
|
182
|
+
delayMs={5000}
|
|
183
|
+
resetAfterDays={7}
|
|
184
|
+
/>
|
|
185
|
+
</DjangoPushProvider>
|
|
186
|
+
</PwaProvider>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Browser Support
|
|
192
|
+
|
|
193
|
+
| Platform | Browser | Support |
|
|
194
|
+
|----------|---------|---------|
|
|
195
|
+
| iOS | Safari | ✅ Visual guide |
|
|
196
|
+
| iOS | Chrome/Firefox | ✅ Visual guide (via Share menu) |
|
|
197
|
+
| Android | Chrome | ✅ Native prompt |
|
|
198
|
+
| Android | Firefox | ⚠️ Manual only |
|
|
199
|
+
| Desktop | Chrome/Edge | ✅ Native prompt |
|
|
200
|
+
|
|
201
|
+
## Architecture
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
PWAInstall/
|
|
205
|
+
├── context/
|
|
206
|
+
│ └── InstallContext.tsx # Install state management
|
|
207
|
+
├── components/
|
|
208
|
+
│ ├── A2HSHint.tsx # Unified install hint
|
|
209
|
+
│ ├── IOSGuide.tsx # Visual guide wrapper
|
|
210
|
+
│ ├── IOSGuideDrawer.tsx # Mobile guide
|
|
211
|
+
│ ├── IOSGuideModal.tsx # Desktop guide
|
|
212
|
+
│ └── PWAPageResumeManager.tsx # Page resume component
|
|
213
|
+
├── hooks/
|
|
214
|
+
│ ├── useInstallPrompt.ts # Install prompt logic
|
|
215
|
+
│ ├── useIsPWA.ts # PWA detection
|
|
216
|
+
│ └── usePWAPageResume.ts # Page resume on PWA launch
|
|
217
|
+
├── utils/
|
|
218
|
+
│ ├── platform.ts # Platform detection
|
|
219
|
+
│ ├── localStorage.ts # Persistence
|
|
220
|
+
│ └── logger.ts # Logging
|
|
221
|
+
└── types/
|
|
222
|
+
├── platform.ts
|
|
223
|
+
├── install.ts
|
|
224
|
+
├── config.ts
|
|
225
|
+
└── components.ts
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Separation from Push Notifications
|
|
229
|
+
|
|
230
|
+
This snippet is **completely independent** from push notifications:
|
|
231
|
+
|
|
232
|
+
- **PWAInstall** → Handles device installation
|
|
233
|
+
- **PushNotifications** → Handles web push subscriptions
|
|
234
|
+
|
|
235
|
+
Both can be used together or separately.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PWA Install Hint (Unified for iOS & Android)
|
|
5
|
+
*
|
|
6
|
+
* Inline, non-blocking hint that shows at the bottom of the screen
|
|
7
|
+
* - iOS Safari: Opens visual guide on click
|
|
8
|
+
* - Android Chrome: Triggers native install prompt on click
|
|
9
|
+
* - Unified UX: Same position, same style, same behavior
|
|
10
|
+
*
|
|
11
|
+
* Auto-resets after 3 days (configurable)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ChevronRight, Download, Share, X } from 'lucide-react';
|
|
15
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
19
|
+
|
|
20
|
+
import { useInstall } from '../context/InstallContext';
|
|
21
|
+
import { isA2HSDismissedRecently, markA2HSDismissed } from '../utils/localStorage';
|
|
22
|
+
import { pwaLogger } from '../utils/logger';
|
|
23
|
+
import { DesktopGuide } from './DesktopGuide';
|
|
24
|
+
import { IOSGuide } from './IOSGuide';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_RESET_DAYS = 3;
|
|
27
|
+
|
|
28
|
+
interface A2HSHintProps {
|
|
29
|
+
/**
|
|
30
|
+
* Additional class names for the container
|
|
31
|
+
*/
|
|
32
|
+
className?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Number of days before re-showing dismissed hint
|
|
36
|
+
* @default 3
|
|
37
|
+
* Set to null to never reset (show once forever)
|
|
38
|
+
*/
|
|
39
|
+
resetAfterDays?: number | null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delay before showing hint (ms)
|
|
43
|
+
* @default 3000
|
|
44
|
+
*/
|
|
45
|
+
delayMs?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Demo mode - shows hint on all platforms with appropriate guides
|
|
49
|
+
* Production: only iOS Safari & Android Chrome
|
|
50
|
+
* Demo: shows on desktop too with desktop install guide
|
|
51
|
+
* @default false
|
|
52
|
+
*/
|
|
53
|
+
demo?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* App logo URL to display in hint
|
|
57
|
+
* If not provided, uses Share icon
|
|
58
|
+
*/
|
|
59
|
+
logo?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function A2HSHint({
|
|
63
|
+
className,
|
|
64
|
+
resetAfterDays = DEFAULT_RESET_DAYS,
|
|
65
|
+
delayMs = 3000,
|
|
66
|
+
demo = false,
|
|
67
|
+
logo,
|
|
68
|
+
}: A2HSHintProps = {}) {
|
|
69
|
+
const { isIOS, isDesktop, isInstalled, canPrompt, install } = useInstall();
|
|
70
|
+
const [show, setShow] = useState(false);
|
|
71
|
+
const [showGuide, setShowGuide] = useState(false);
|
|
72
|
+
const [installing, setInstalling] = useState(false);
|
|
73
|
+
const t = useAppT();
|
|
74
|
+
|
|
75
|
+
const labels = useMemo(() => ({
|
|
76
|
+
addToHomeScreen: t('layouts.pwa.addToHomeScreen'),
|
|
77
|
+
installApp: t('layouts.pwa.installApp'),
|
|
78
|
+
tapToLearnHow: t('layouts.pwa.tapToLearnHow'),
|
|
79
|
+
clickToSeeGuide: t('layouts.pwa.clickToSeeGuide'),
|
|
80
|
+
tapToInstall: t('layouts.pwa.tapToInstall'),
|
|
81
|
+
installing: t('layouts.pwa.installing'),
|
|
82
|
+
dismiss: t('layouts.pwa.dismiss'),
|
|
83
|
+
appLogo: t('layouts.pwa.appLogo'),
|
|
84
|
+
}), [t]);
|
|
85
|
+
|
|
86
|
+
// Determine if should show hint
|
|
87
|
+
// Production: iOS (all browsers support PWA via Share) & Android Chrome with native prompt
|
|
88
|
+
// Demo: show on all platforms (desktop, iOS, Android)
|
|
89
|
+
const shouldShow = demo
|
|
90
|
+
? !isInstalled // Demo: show on all platforms if not installed
|
|
91
|
+
: !isInstalled && (isIOS || canPrompt); // Production: iOS (all browsers) or Android with prompt
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!shouldShow) return;
|
|
95
|
+
|
|
96
|
+
// Check if previously dismissed (skip localStorage check in demo mode)
|
|
97
|
+
if (!demo && typeof window !== 'undefined') {
|
|
98
|
+
// If resetAfterDays is null, never reset (check with very large number)
|
|
99
|
+
if (resetAfterDays === null) {
|
|
100
|
+
if (isA2HSDismissedRecently(Number.MAX_SAFE_INTEGER)) {
|
|
101
|
+
return; // Dismissed forever
|
|
102
|
+
}
|
|
103
|
+
} else if (isA2HSDismissedRecently(resetAfterDays)) {
|
|
104
|
+
return; // Still within reset period
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Show after delay (user is already engaged)
|
|
109
|
+
const timer = setTimeout(() => setShow(true), delayMs);
|
|
110
|
+
return () => clearTimeout(timer);
|
|
111
|
+
}, [shouldShow, resetAfterDays, delayMs, demo]);
|
|
112
|
+
|
|
113
|
+
const handleDismiss = () => {
|
|
114
|
+
setShow(false);
|
|
115
|
+
// Don't save to localStorage if demo mode (dev testing)
|
|
116
|
+
if (!demo) {
|
|
117
|
+
markA2HSDismissed();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleGuideDismiss = () => {
|
|
122
|
+
setShowGuide(false);
|
|
123
|
+
// In demo mode, keep hint visible after guide is dismissed
|
|
124
|
+
// In production mode, dismiss both guide and hint
|
|
125
|
+
if (!demo) {
|
|
126
|
+
handleDismiss();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleClick = async () => {
|
|
131
|
+
// iOS (all browsers support PWA via Share menu)
|
|
132
|
+
// iOS or Desktop: Open visual guide
|
|
133
|
+
if (isIOS || isDesktop) {
|
|
134
|
+
setShowGuide(true);
|
|
135
|
+
} else if (canPrompt) {
|
|
136
|
+
// Android: Trigger native install prompt
|
|
137
|
+
setInstalling(true);
|
|
138
|
+
try {
|
|
139
|
+
await install();
|
|
140
|
+
// If install succeeds, dismiss hint
|
|
141
|
+
handleDismiss();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
pwaLogger.error('[A2HSHint] Install error:', error);
|
|
144
|
+
} finally {
|
|
145
|
+
setInstalling(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (!show) return null;
|
|
151
|
+
|
|
152
|
+
// Determine which guide/action to show
|
|
153
|
+
let title: string;
|
|
154
|
+
let subtitle: React.ReactNode;
|
|
155
|
+
|
|
156
|
+
if (isIOS) {
|
|
157
|
+
title = labels.addToHomeScreen;
|
|
158
|
+
subtitle = <>{labels.tapToLearnHow} <ChevronRight className="w-3 h-3" /></>;
|
|
159
|
+
} else if (isDesktop) {
|
|
160
|
+
title = labels.installApp;
|
|
161
|
+
subtitle = <>{labels.clickToSeeGuide} <ChevronRight className="w-3 h-3" /></>;
|
|
162
|
+
} else {
|
|
163
|
+
// Android or other mobile with native prompt
|
|
164
|
+
title = labels.installApp;
|
|
165
|
+
subtitle = <>{labels.tapToInstall} <Download className="w-3 h-3" /></>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<div className={cn(
|
|
171
|
+
"fixed bottom-4 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 duration-300",
|
|
172
|
+
demo && "relative inset-auto z-auto", // Demo mode: remove fixed positioning
|
|
173
|
+
className
|
|
174
|
+
)}>
|
|
175
|
+
{/* Make entire card clickable */}
|
|
176
|
+
<div
|
|
177
|
+
role="button"
|
|
178
|
+
tabIndex={0}
|
|
179
|
+
onClick={handleClick}
|
|
180
|
+
onKeyDown={(e) => {
|
|
181
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
handleClick();
|
|
184
|
+
}
|
|
185
|
+
}}
|
|
186
|
+
className={cn(
|
|
187
|
+
"w-full bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-lg cursor-pointer hover:bg-zinc-800 transition-colors",
|
|
188
|
+
installing && "opacity-70 cursor-not-allowed"
|
|
189
|
+
)}
|
|
190
|
+
aria-disabled={installing}
|
|
191
|
+
>
|
|
192
|
+
<div className="flex items-center gap-3">
|
|
193
|
+
{/* App logo or icon */}
|
|
194
|
+
<div className="flex-shrink-0">
|
|
195
|
+
{logo ? (
|
|
196
|
+
<img src={logo} alt={labels.appLogo} className="w-10 h-10 rounded-lg" />
|
|
197
|
+
) : (
|
|
198
|
+
<Share className="w-5 h-5 text-blue-400" />
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Content - now just displays, clicking anywhere triggers action */}
|
|
203
|
+
<div className="flex-1 min-w-0">
|
|
204
|
+
<p className="text-sm font-medium text-white mb-1">{title}</p>
|
|
205
|
+
<p className="text-xs text-zinc-400 flex items-center gap-1">
|
|
206
|
+
{installing ? labels.installing : subtitle}
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Close button - prevent event bubbling */}
|
|
211
|
+
<button
|
|
212
|
+
onClick={(e) => {
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
handleDismiss();
|
|
215
|
+
}}
|
|
216
|
+
className="flex-shrink-0 p-1 hover:bg-zinc-700 rounded transition-colors"
|
|
217
|
+
aria-label={labels.dismiss}
|
|
218
|
+
>
|
|
219
|
+
<X className="w-4 h-4 text-zinc-400" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Show appropriate guide based on platform */}
|
|
226
|
+
{isIOS && (
|
|
227
|
+
<IOSGuide open={showGuide} onDismiss={handleGuideDismiss} />
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Desktop guide */}
|
|
231
|
+
{isDesktop && (
|
|
232
|
+
<DesktopGuide open={showGuide} onDismiss={handleGuideDismiss} />
|
|
233
|
+
)}
|
|
234
|
+
</>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Desktop Installation Guide Modal
|
|
5
|
+
*
|
|
6
|
+
* Visual step-by-step guide for installing PWA on desktop browsers
|
|
7
|
+
* Uses platform detection from InstallContext
|
|
8
|
+
* Supports: Chrome, Edge, Brave, Arc, Vivaldi, Opera, Yandex, Firefox, Safari
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ArrowDownToLine, Check, Menu, Monitor, Plus, Search } from 'lucide-react';
|
|
12
|
+
import React, { useMemo } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
15
|
+
import {
|
|
16
|
+
Button, Card, CardContent, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
|
|
17
|
+
DialogTitle
|
|
18
|
+
} from '@djangocfg/ui-core/components';
|
|
19
|
+
|
|
20
|
+
import { useInstall } from '../context/InstallContext';
|
|
21
|
+
|
|
22
|
+
import type { IOSGuideModalProps, InstallStep } from '../types';
|
|
23
|
+
type BrowserCategory = 'chromium' | 'firefox' | 'safari' | 'unknown';
|
|
24
|
+
|
|
25
|
+
function getBrowserCategory(browser: {
|
|
26
|
+
isChromium: boolean;
|
|
27
|
+
isFirefox: boolean;
|
|
28
|
+
isSafari: boolean;
|
|
29
|
+
}): BrowserCategory {
|
|
30
|
+
if (browser.isChromium) return 'chromium';
|
|
31
|
+
if (browser.isFirefox) return 'firefox';
|
|
32
|
+
if (browser.isSafari) return 'safari';
|
|
33
|
+
return 'unknown';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type TranslationFn = (key: any) => string;
|
|
37
|
+
|
|
38
|
+
function getBrowserSteps(category: BrowserCategory, t: TranslationFn): InstallStep[] {
|
|
39
|
+
switch (category) {
|
|
40
|
+
case 'chromium':
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
number: 1,
|
|
44
|
+
title: t('layouts.pwa.chromiumStep1Title'),
|
|
45
|
+
icon: ArrowDownToLine,
|
|
46
|
+
description: t('layouts.pwa.chromiumStep1Desc'),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
number: 2,
|
|
50
|
+
title: t('layouts.pwa.chromiumStep2Title'),
|
|
51
|
+
icon: Plus,
|
|
52
|
+
description: t('layouts.pwa.chromiumStep2Desc'),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
number: 3,
|
|
56
|
+
title: t('layouts.pwa.chromiumStep3Title'),
|
|
57
|
+
icon: Check,
|
|
58
|
+
description: t('layouts.pwa.chromiumStep3Desc'),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
case 'firefox':
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
number: 1,
|
|
66
|
+
title: t('layouts.pwa.firefoxStep1Title'),
|
|
67
|
+
icon: Menu,
|
|
68
|
+
description: t('layouts.pwa.firefoxStep1Desc'),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
number: 2,
|
|
72
|
+
title: t('layouts.pwa.firefoxStep2Title'),
|
|
73
|
+
icon: Search,
|
|
74
|
+
description: t('layouts.pwa.firefoxStep2Desc'),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
number: 3,
|
|
78
|
+
title: t('layouts.pwa.firefoxStep3Title'),
|
|
79
|
+
icon: Check,
|
|
80
|
+
description: t('layouts.pwa.firefoxStep3Desc'),
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
case 'safari':
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
number: 1,
|
|
88
|
+
title: t('layouts.pwa.safariStep1Title'),
|
|
89
|
+
icon: Monitor,
|
|
90
|
+
description: t('layouts.pwa.safariStep1Desc'),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
number: 2,
|
|
94
|
+
title: t('layouts.pwa.safariStep2Title'),
|
|
95
|
+
icon: ArrowDownToLine,
|
|
96
|
+
description: t('layouts.pwa.safariStep2Desc'),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
number: 1,
|
|
104
|
+
title: t('layouts.pwa.unknownStep1Title'),
|
|
105
|
+
icon: ArrowDownToLine,
|
|
106
|
+
description: t('layouts.pwa.unknownStep1Desc'),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
number: 2,
|
|
110
|
+
title: t('layouts.pwa.unknownStep2Title'),
|
|
111
|
+
icon: Menu,
|
|
112
|
+
description: t('layouts.pwa.unknownStep2Desc'),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
number: 3,
|
|
116
|
+
title: t('layouts.pwa.unknownStep3Title'),
|
|
117
|
+
icon: Check,
|
|
118
|
+
description: t('layouts.pwa.unknownStep3Desc'),
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
125
|
+
return (
|
|
126
|
+
<Card className="border border-border">
|
|
127
|
+
<CardContent className="p-4">
|
|
128
|
+
<div className="flex items-start gap-3">
|
|
129
|
+
<div
|
|
130
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
131
|
+
style={{ width: '32px', height: '32px' }}
|
|
132
|
+
>
|
|
133
|
+
<span className="text-sm font-semibold">{step.number}</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="flex-1 min-w-0">
|
|
137
|
+
<div className="flex items-center gap-2 mb-1">
|
|
138
|
+
<step.icon className="w-5 h-5 text-primary" />
|
|
139
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
140
|
+
</div>
|
|
141
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</CardContent>
|
|
145
|
+
</Card>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function DesktopGuide({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
150
|
+
const {
|
|
151
|
+
browserName,
|
|
152
|
+
isChromium,
|
|
153
|
+
isFirefox,
|
|
154
|
+
isSafari,
|
|
155
|
+
isEdge,
|
|
156
|
+
isBrave,
|
|
157
|
+
isArc,
|
|
158
|
+
isVivaldi,
|
|
159
|
+
isOpera,
|
|
160
|
+
isYandex,
|
|
161
|
+
} = useInstall();
|
|
162
|
+
const t = useAppT();
|
|
163
|
+
|
|
164
|
+
const labels = useMemo(() => ({
|
|
165
|
+
title: t('layouts.pwa.desktopTitle'),
|
|
166
|
+
description: t('layouts.pwa.desktopDescription'),
|
|
167
|
+
descSafari: t('layouts.pwa.desktopDescSafari'),
|
|
168
|
+
tip: t('layouts.pwa.desktopTip'),
|
|
169
|
+
firefoxNote: t('layouts.pwa.desktopFirefoxNote'),
|
|
170
|
+
gotIt: t('layouts.pwa.gotIt'),
|
|
171
|
+
}), [t]);
|
|
172
|
+
|
|
173
|
+
const category = useMemo(
|
|
174
|
+
() => getBrowserCategory({ isChromium, isFirefox, isSafari }),
|
|
175
|
+
[isChromium, isFirefox, isSafari]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const steps = useMemo(() => getBrowserSteps(category, t), [category, t]);
|
|
179
|
+
|
|
180
|
+
// Get specific browser display name
|
|
181
|
+
const displayName = useMemo(() => {
|
|
182
|
+
if (isEdge) return 'Edge';
|
|
183
|
+
if (isBrave) return 'Brave';
|
|
184
|
+
if (isArc) return 'Arc';
|
|
185
|
+
if (isVivaldi) return 'Vivaldi';
|
|
186
|
+
if (isOpera) return 'Opera';
|
|
187
|
+
if (isYandex) return 'Yandex Browser';
|
|
188
|
+
return browserName;
|
|
189
|
+
}, [browserName, isEdge, isBrave, isArc, isVivaldi, isOpera, isYandex]);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
193
|
+
<DialogContent className="sm:max-w-md">
|
|
194
|
+
<DialogHeader className="text-left">
|
|
195
|
+
<DialogTitle className="flex items-center gap-2">
|
|
196
|
+
<Monitor className="w-5 h-5 text-primary" />
|
|
197
|
+
{labels.title}
|
|
198
|
+
</DialogTitle>
|
|
199
|
+
<DialogDescription className="text-left">
|
|
200
|
+
{isSafari
|
|
201
|
+
? labels.descSafari
|
|
202
|
+
: labels.description.replace('{browser}', displayName)
|
|
203
|
+
}
|
|
204
|
+
</DialogDescription>
|
|
205
|
+
</DialogHeader>
|
|
206
|
+
|
|
207
|
+
<div className="space-y-3 py-4">
|
|
208
|
+
{steps.map((step) => (
|
|
209
|
+
<StepCard key={step.number} step={step} />
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{category === 'chromium' && (
|
|
214
|
+
<div className="p-3 bg-muted/30 rounded-lg text-xs text-muted-foreground">
|
|
215
|
+
💡 {labels.tip.replace('{browser}', displayName)}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{category === 'firefox' && (
|
|
220
|
+
<div className="p-3 bg-amber-500/10 rounded-lg text-xs text-amber-700 dark:text-amber-400">
|
|
221
|
+
ℹ️ {labels.firefoxNote}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
<DialogFooter>
|
|
226
|
+
<Button onClick={onDismiss} variant="default" className="w-full">
|
|
227
|
+
<Check className="w-4 h-4 mr-2" />
|
|
228
|
+
{labels.gotIt}
|
|
229
|
+
</Button>
|
|
230
|
+
</DialogFooter>
|
|
231
|
+
</DialogContent>
|
|
232
|
+
</Dialog>
|
|
233
|
+
);
|
|
234
|
+
}
|