@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,92 @@
|
|
|
1
|
+
# PWAInstall Documentation
|
|
2
|
+
|
|
3
|
+
Comprehensive documentation for the PWAInstall snippet.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
PWAInstall handles **Progressive Web App installation** on user devices (Add to Home Screen functionality).
|
|
8
|
+
|
|
9
|
+
**Responsibility**: Device installation only (not push notifications)
|
|
10
|
+
|
|
11
|
+
## Documentation Structure
|
|
12
|
+
|
|
13
|
+
### `/research/`
|
|
14
|
+
Research and best practices:
|
|
15
|
+
- **[ios-android-install-flows.md](./research/ios-android-install-flows.md)** - iOS vs Android PWA installation patterns, limitations, and best practices (2024-2025)
|
|
16
|
+
|
|
17
|
+
### `/architecture/`
|
|
18
|
+
Architecture and design:
|
|
19
|
+
- Coming soon: Architecture decisions, component design, state management
|
|
20
|
+
|
|
21
|
+
### `/legacy/`
|
|
22
|
+
Historical documentation:
|
|
23
|
+
- Old architecture analysis (before snippet split)
|
|
24
|
+
- Refactoring history
|
|
25
|
+
|
|
26
|
+
## Quick Navigation
|
|
27
|
+
|
|
28
|
+
### For Users
|
|
29
|
+
Start here if you want to use PWAInstall:
|
|
30
|
+
- [Main README](../README.md) - Quick start and API reference
|
|
31
|
+
- [Migration Guide](../../MIGRATION.md) - Migrating from old PWA snippet
|
|
32
|
+
|
|
33
|
+
### For Contributors
|
|
34
|
+
Start here if you want to understand or modify PWAInstall:
|
|
35
|
+
- [iOS/Android Install Flows](./research/ios-android-install-flows.md) - Platform-specific behavior
|
|
36
|
+
- [Architecture](./architecture/) - How it's built
|
|
37
|
+
|
|
38
|
+
### For Researchers
|
|
39
|
+
Start here if you want to understand PWA installation patterns:
|
|
40
|
+
- [Research](./research/) - Industry research and best practices
|
|
41
|
+
|
|
42
|
+
## Key Concepts
|
|
43
|
+
|
|
44
|
+
### Platform Asymmetry
|
|
45
|
+
|
|
46
|
+
| Aspect | Android Chrome | iOS Safari |
|
|
47
|
+
|--------|----------------|------------|
|
|
48
|
+
| Install API | `beforeinstallprompt` | ❌ No API |
|
|
49
|
+
| User effort | 1 tap | 3-4 taps |
|
|
50
|
+
| Detection | Event-based | Heuristic |
|
|
51
|
+
| Guidance | Optional | **Required** |
|
|
52
|
+
|
|
53
|
+
**PWAInstall handles this asymmetry transparently.**
|
|
54
|
+
|
|
55
|
+
### Components
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
A2HSHint (Unified hint)
|
|
59
|
+
├── Android → Native install prompt
|
|
60
|
+
└── iOS → Visual guide (IOSGuide)
|
|
61
|
+
├── Mobile → IOSGuideDrawer
|
|
62
|
+
└── Desktop → IOSGuideModal
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### State Management
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
useInstall() hook
|
|
69
|
+
├── Platform detection (isIOS, isAndroid, isSafari)
|
|
70
|
+
├── Installation state (isInstalled, canPrompt)
|
|
71
|
+
└── Install action (install())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Related Documentation
|
|
75
|
+
|
|
76
|
+
- **[PushNotifications Docs](../../PushNotifications/@docs/)** - Web push notifications (separate concern)
|
|
77
|
+
- **[Refactoring Summary](../../REFACTORING_SUMMARY.md)** - Why snippets were split
|
|
78
|
+
- **[Migration Guide](../../MIGRATION.md)** - How to migrate from old PWA snippet
|
|
79
|
+
|
|
80
|
+
## Contributing
|
|
81
|
+
|
|
82
|
+
When adding documentation:
|
|
83
|
+
1. **Research** → `/research/` - Industry patterns, browser behavior
|
|
84
|
+
2. **Architecture** → `/architecture/` - Design decisions, component structure
|
|
85
|
+
3. **Historical** → `/legacy/` - Old docs (keep for reference)
|
|
86
|
+
|
|
87
|
+
## Questions?
|
|
88
|
+
|
|
89
|
+
- Implementation questions → See [Main README](../README.md)
|
|
90
|
+
- Architecture questions → See [/architecture/](./architecture/)
|
|
91
|
+
- Platform behavior → See [/research/](./research/)
|
|
92
|
+
- Migration questions → See [Migration Guide](../../MIGRATION.md)
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## PWA Install Flows: Modern Best Practices for React (2024-2025)
|
|
5
|
+
|
|
6
|
+
Given your experience with Django and Next.js infrastructure, you'll appreciate that PWA install flows are fundamentally about **adaptive UX patterns**, not magic APIs. Let me break down the reality vs. the aspirations.
|
|
7
|
+
|
|
8
|
+
***
|
|
9
|
+
|
|
10
|
+
### **The Core Problem: iOS vs. Android Asymmetry**
|
|
11
|
+
|
|
12
|
+
| Aspect | Android (Chrome) | iOS (Safari) |
|
|
13
|
+
|--------|------------------|--------------|
|
|
14
|
+
| **Install API** | `beforeinstallprompt` event + native prompt | ❌ No API, no native banner |
|
|
15
|
+
| **User effort** | 1 tap (native prompt) | 3-4 taps (Share → Add to Home Screen) |
|
|
16
|
+
| **App awareness** | Chrome auto-prompts if installable | **You must educate users manually** |
|
|
17
|
+
| **Detection** | Straightforward event-based | Must use heuristics (Safari + mobile) |
|
|
18
|
+
| **Persistence** | Browser remembers install state | No native tracking |
|
|
19
|
+
| **After install** | `appinstalled` event fires | Must detect via `standalone` flag |
|
|
20
|
+
|
|
21
|
+
**The brutal truth:** iOS Safari treats PWAs as "websites you happened to bookmark"—there's no concept of "installation" from Apple's perspective. You're responsible for education and guidance.
|
|
22
|
+
|
|
23
|
+
***
|
|
24
|
+
|
|
25
|
+
### **What's Actually Possible vs. Impossible on iOS**
|
|
26
|
+
|
|
27
|
+
#### ✅ **Possible (2024-2025)**
|
|
28
|
+
|
|
29
|
+
1. **Detect if running as standalone (already installed)**
|
|
30
|
+
```javascript
|
|
31
|
+
const isInstalled = window.matchMedia("(display-mode: standalone)").matches
|
|
32
|
+
|| navigator.standalone === true;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
2. **Detect iOS + Safari combination**
|
|
36
|
+
- User agent parsing (for initial load)
|
|
37
|
+
- Browser capability detection
|
|
38
|
+
|
|
39
|
+
3. **Show custom guidance UI with visual/text instructions**
|
|
40
|
+
- No technical restrictions on UI—you can display modal, banner, tooltip, etc.
|
|
41
|
+
|
|
42
|
+
4. **Persist user guidance state (show once)**
|
|
43
|
+
- localStorage, IndexedDB, or cookies
|
|
44
|
+
|
|
45
|
+
5. **Use web standards:**
|
|
46
|
+
- Web App Manifest (icon, splash screen, display mode)
|
|
47
|
+
- Service Workers (offline, performance)
|
|
48
|
+
- Media queries for standalone detection
|
|
49
|
+
|
|
50
|
+
#### ❌ **Impossible (Hard Limits)**
|
|
51
|
+
|
|
52
|
+
1. **Programmatically trigger install prompt** ← iOS blocks this intentionally
|
|
53
|
+
2. **Native install banner** ← Apple doesn't expose one
|
|
54
|
+
3. **beforeinstallprompt event** ← iOS doesn't fire it
|
|
55
|
+
4. **Push notifications on PWA** ← Disabled by Apple for PWAs (only for native apps)
|
|
56
|
+
5. **Detect `appinstalled` event** ← iOS doesn't fire this
|
|
57
|
+
6. **Background sync / periodic background sync** ← Not available for PWAs on iOS
|
|
58
|
+
7. **File system access** ← Blocked by iOS sandbox
|
|
59
|
+
8. **Native app store integration** ← You're not in the App Store
|
|
60
|
+
|
|
61
|
+
***
|
|
62
|
+
|
|
63
|
+
### **Browser & Environment Detection (React Hook)**
|
|
64
|
+
|
|
65
|
+
Here's a production-ready detection hook that handles all the cases:
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
// useInstallPrompt.js
|
|
69
|
+
import { useEffect, useState } from 'react';
|
|
70
|
+
|
|
71
|
+
export function useInstallPrompt() {
|
|
72
|
+
const [state, setState] = useState({
|
|
73
|
+
isIOS: false,
|
|
74
|
+
isAndroid: false,
|
|
75
|
+
isSafari: false,
|
|
76
|
+
isChrome: false,
|
|
77
|
+
isInstalled: false, // Already added to home screen
|
|
78
|
+
canPrompt: false, // beforeinstallprompt available (Android)
|
|
79
|
+
deferredPrompt: null,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
// Detect OS
|
|
84
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
85
|
+
const isIOS = /iphone|ipad|ipod/.test(ua);
|
|
86
|
+
const isAndroid = /android/.test(ua);
|
|
87
|
+
|
|
88
|
+
// Detect browser
|
|
89
|
+
const isSafari = /safari/.test(ua) && !/chrome/.test(ua) && !/edge/.test(ua);
|
|
90
|
+
const isChrome = /chrome|chromium/.test(ua);
|
|
91
|
+
|
|
92
|
+
// Detect if already installed (running as PWA on home screen)
|
|
93
|
+
const isInstalled =
|
|
94
|
+
window.matchMedia("(display-mode: standalone)").matches ||
|
|
95
|
+
navigator.standalone === true; // Legacy iOS check
|
|
96
|
+
|
|
97
|
+
setState(prev => ({
|
|
98
|
+
...prev,
|
|
99
|
+
isIOS,
|
|
100
|
+
isAndroid,
|
|
101
|
+
isSafari,
|
|
102
|
+
isChrome,
|
|
103
|
+
isInstalled,
|
|
104
|
+
}));
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Listen for beforeinstallprompt (Android Chrome only)
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const handleBeforeInstallPrompt = (e) => {
|
|
110
|
+
e.preventDefault(); // Don't show native prompt yet
|
|
111
|
+
setState(prev => ({
|
|
112
|
+
...prev,
|
|
113
|
+
canPrompt: true,
|
|
114
|
+
deferredPrompt: e,
|
|
115
|
+
}));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
119
|
+
|
|
120
|
+
// Clean up: if app gets installed, can't prompt again
|
|
121
|
+
const handleAppInstalled = () => {
|
|
122
|
+
setState(prev => ({
|
|
123
|
+
...prev,
|
|
124
|
+
canPrompt: false,
|
|
125
|
+
deferredPrompt: null,
|
|
126
|
+
isInstalled: true,
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
131
|
+
|
|
132
|
+
return () => {
|
|
133
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
134
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
135
|
+
};
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
// Trigger Android native prompt
|
|
139
|
+
const promptInstall = async () => {
|
|
140
|
+
if (!state.deferredPrompt) return null;
|
|
141
|
+
|
|
142
|
+
state.deferredPrompt.prompt();
|
|
143
|
+
const { outcome } = await state.deferredPrompt.userChoice;
|
|
144
|
+
setState(prev => ({
|
|
145
|
+
...prev,
|
|
146
|
+
deferredPrompt: null,
|
|
147
|
+
canPrompt: false,
|
|
148
|
+
}));
|
|
149
|
+
return outcome; // 'accepted' or 'dismissed'
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
promptInstall,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
***
|
|
160
|
+
|
|
161
|
+
### **Real-World UX Patterns That Actually Work**
|
|
162
|
+
|
|
163
|
+
#### **Pattern 1: The Adaptive Install Banner (Recommended)**
|
|
164
|
+
|
|
165
|
+
**For first-time visitors:**
|
|
166
|
+
|
|
167
|
+
1. **Android Chrome**: Show custom "Install" button in navbar/footer
|
|
168
|
+
- Click → triggers native prompt via `beforeinstallprompt`
|
|
169
|
+
- Non-intrusive, native look
|
|
170
|
+
|
|
171
|
+
2. **iOS Safari**: Show subtle banner/tooltip on first visit
|
|
172
|
+
- Text: "Add to Home Screen for quick access"
|
|
173
|
+
- Visual: Share icon + "Add to Home Screen" steps
|
|
174
|
+
- Dismiss-able, one-time only
|
|
175
|
+
|
|
176
|
+
3. **Already installed**: Hide all prompts
|
|
177
|
+
|
|
178
|
+
**Implementation strategy:**
|
|
179
|
+
- Show Android button immediately (it's native, trusted)
|
|
180
|
+
- Delay iOS banner 2-3 seconds (let them explore first)
|
|
181
|
+
- Use localStorage to track "dismissed once" per user
|
|
182
|
+
- Reset for new visitors (check last visit date)
|
|
183
|
+
|
|
184
|
+
***
|
|
185
|
+
|
|
186
|
+
#### **Pattern 2: Context-Aware Prompts (Based on Engagement)**
|
|
187
|
+
|
|
188
|
+
**Show iOS guidance when:**
|
|
189
|
+
- User has spent 30+ seconds on the app
|
|
190
|
+
- Completed first action (e.g., created note, searched)
|
|
191
|
+
- Returning visitor (showed visit count in localStorage)
|
|
192
|
+
|
|
193
|
+
**Logic:**
|
|
194
|
+
```javascript
|
|
195
|
+
// Pseudocode
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (isIOS && !isInstalled && !isDismissedRecently) {
|
|
198
|
+
const timer = setTimeout(() => {
|
|
199
|
+
if (engagementMetrics.timeSpent > 30000 || engagementMetrics.actions > 1) {
|
|
200
|
+
showIOSGuideModal();
|
|
201
|
+
}
|
|
202
|
+
}, 2000); // Check after 2 seconds
|
|
203
|
+
|
|
204
|
+
return () => clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
}, [isIOS, isInstalled]);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Why it works:**
|
|
210
|
+
- Users are already bought-in (they've engaged)
|
|
211
|
+
- Feels less spammy
|
|
212
|
+
- Higher conversion rates
|
|
213
|
+
|
|
214
|
+
***
|
|
215
|
+
|
|
216
|
+
#### **Pattern 3: Visual Inline Instructions (The iOS Workaround)**
|
|
217
|
+
|
|
218
|
+
Since iOS has no API, show a **visual + text guide**:
|
|
219
|
+
|
|
220
|
+
```jsx
|
|
221
|
+
<IOSInstallGuide />
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Show:
|
|
225
|
+
1. Screenshot of Safari toolbar
|
|
226
|
+
2. "Tap Share button (↗️)"
|
|
227
|
+
3. "Swipe down, tap 'Add to Home Screen'"
|
|
228
|
+
4. "Tap 'Add' in top-right"
|
|
229
|
+
5. "App appears on home screen"
|
|
230
|
+
|
|
231
|
+
**Best practices:**
|
|
232
|
+
- Use actual iOS system fonts and colors
|
|
233
|
+
- Show real screenshots, not cartoons
|
|
234
|
+
- Make dismissible with "I'll do it later" option
|
|
235
|
+
- Track if dismissed (localStorage key)
|
|
236
|
+
|
|
237
|
+
***
|
|
238
|
+
|
|
239
|
+
### **Example React Component: Complete Install Manager**
|
|
240
|
+
|
|
241
|
+
```javascript
|
|
242
|
+
// InstallManager.jsx
|
|
243
|
+
import { useEffect, useState } from 'react';
|
|
244
|
+
import { useInstallPrompt } from './useInstallPrompt';
|
|
245
|
+
import IOSGuideModal from './modals/IOSGuideModal';
|
|
246
|
+
import AndroidInstallButton from './buttons/AndroidInstallButton';
|
|
247
|
+
|
|
248
|
+
export default function InstallManager() {
|
|
249
|
+
const install = useInstallPrompt();
|
|
250
|
+
const [showIOSGuide, setShowIOSGuide] = useState(false);
|
|
251
|
+
const [dismissalTime, setDismissalTime] = useState(null);
|
|
252
|
+
|
|
253
|
+
// Initialize state from localStorage
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const stored = localStorage.getItem('ios_guide_dismissed_at');
|
|
256
|
+
if (stored) setDismissalTime(parseInt(stored, 10));
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
// Determine if should show iOS guide
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!install.isIOS || install.isInstalled || install.isSafari === false) {
|
|
262
|
+
setShowIOSGuide(false);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check if dismissed recently (within 7 days)
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const WEEK = 7 * 24 * 60 * 60 * 1000;
|
|
269
|
+
if (dismissalTime && now - dismissalTime < WEEK) {
|
|
270
|
+
setShowIOSGuide(false);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Show after 2 seconds if not dismissed
|
|
275
|
+
const timer = setTimeout(() => setShowIOSGuide(true), 2000);
|
|
276
|
+
return () => clearTimeout(timer);
|
|
277
|
+
}, [install.isIOS, install.isInstalled, install.isSafari, dismissalTime]);
|
|
278
|
+
|
|
279
|
+
const handleIOSDismiss = () => {
|
|
280
|
+
setShowIOSGuide(false);
|
|
281
|
+
localStorage.setItem('ios_guide_dismissed_at', Date.now().toString());
|
|
282
|
+
setDismissalTime(Date.now());
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Don't render anything if already installed
|
|
286
|
+
if (install.isInstalled) return null;
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
{/* Android: Show button in navbar/header */}
|
|
291
|
+
{install.isAndroid && install.canPrompt && (
|
|
292
|
+
<AndroidInstallButton onInstall={install.promptInstall} />
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* iOS: Show modal with visual guide */}
|
|
296
|
+
{showIOSGuide && (
|
|
297
|
+
<IOSGuideModal onDismiss={handleIOSDismiss} />
|
|
298
|
+
)}
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```javascript
|
|
305
|
+
// AndroidInstallButton.jsx
|
|
306
|
+
export default function AndroidInstallButton({ onInstall }) {
|
|
307
|
+
const [loading, setLoading] = useState(false);
|
|
308
|
+
|
|
309
|
+
const handleClick = async () => {
|
|
310
|
+
setLoading(true);
|
|
311
|
+
const outcome = await onInstall();
|
|
312
|
+
setLoading(false);
|
|
313
|
+
// outcome will be 'accepted' or 'dismissed'
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<button
|
|
318
|
+
onClick={handleClick}
|
|
319
|
+
disabled={loading}
|
|
320
|
+
className="install-btn"
|
|
321
|
+
>
|
|
322
|
+
{loading ? 'Installing...' : '⬇️ Install App'}
|
|
323
|
+
</button>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```javascript
|
|
329
|
+
// IOSGuideModal.jsx
|
|
330
|
+
export default function IOSGuideModal({ onDismiss }) {
|
|
331
|
+
return (
|
|
332
|
+
<div className="modal-overlay">
|
|
333
|
+
<div className="modal-content">
|
|
334
|
+
<h2>Quick Access on Your Home Screen</h2>
|
|
335
|
+
|
|
336
|
+
<div className="guide-steps">
|
|
337
|
+
<Step number={1} title="Tap Share" icon="↗️">
|
|
338
|
+
<p>At the bottom of Safari</p>
|
|
339
|
+
</Step>
|
|
340
|
+
|
|
341
|
+
<Step number={2} title="Scroll & Tap" icon="👇">
|
|
342
|
+
<p>"Add to Home Screen"</p>
|
|
343
|
+
</Step>
|
|
344
|
+
|
|
345
|
+
<Step number={3} title="Confirm" icon="✓">
|
|
346
|
+
<p>Tap "Add" in the top-right</p>
|
|
347
|
+
</Step>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
<button onClick={onDismiss} className="btn-secondary">
|
|
351
|
+
I'll Do It Later
|
|
352
|
+
</button>
|
|
353
|
+
<button onClick={onDismiss} className="btn-primary">
|
|
354
|
+
Got It!
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
***
|
|
363
|
+
|
|
364
|
+
### **State Persistence Strategy**
|
|
365
|
+
|
|
366
|
+
**Key considerations:**
|
|
367
|
+
|
|
368
|
+
1. **localStorage best choice for PWAs:**
|
|
369
|
+
```javascript
|
|
370
|
+
// Don't re-show guide if dismissed in last 7 days
|
|
371
|
+
const isDismissedRecently = () => {
|
|
372
|
+
const dismissed = localStorage.getItem('ios_guide_last_dismissed');
|
|
373
|
+
if (!dismissed) return false;
|
|
374
|
+
const days = (Date.now() - parseInt(dismissed)) / (1000 * 60 * 60 * 24);
|
|
375
|
+
return days < 7;
|
|
376
|
+
};
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
2. **Track engagement** (optional, for smart prompts):
|
|
380
|
+
```javascript
|
|
381
|
+
// Log engagement metrics
|
|
382
|
+
const logEngagement = (action) => {
|
|
383
|
+
const current = JSON.parse(localStorage.getItem('engagement') || '{"actions":0,"time":0}');
|
|
384
|
+
current.actions += 1;
|
|
385
|
+
localStorage.setItem('engagement', JSON.stringify(current));
|
|
386
|
+
};
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
3. **Clear state on install** (Android):
|
|
390
|
+
```javascript
|
|
391
|
+
// On appinstalled event, clear prompts
|
|
392
|
+
window.addEventListener('appinstalled', () => {
|
|
393
|
+
localStorage.setItem('app_installed', 'true');
|
|
394
|
+
localStorage.removeItem('ios_guide_dismissed_at');
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
***
|
|
399
|
+
|
|
400
|
+
### **Real-World Examples: How Major PWAs Handle This**
|
|
401
|
+
|
|
402
|
+
#### **Twitter/X PWA**
|
|
403
|
+
- **Android**: Native install button in sidebar (when eligible)
|
|
404
|
+
- **iOS**: No visible prompt (assumes power users know how to add to home screen)
|
|
405
|
+
- **Strategy**: Relies on word-of-mouth, not aggressive prompting
|
|
406
|
+
|
|
407
|
+
#### **GitHub PWA**
|
|
408
|
+
- Minimal install flow
|
|
409
|
+
- **Android**: Shows prompt when visiting multiple times
|
|
410
|
+
- **iOS**: Silent (no prompts)
|
|
411
|
+
- **Philosophy**: Let power users discover, don't interrupt
|
|
412
|
+
|
|
413
|
+
#### **Linear PWA**
|
|
414
|
+
- **Android**: Clean install button in top-nav
|
|
415
|
+
- **iOS**: No modal—relies on quality app experience to drive manual adds
|
|
416
|
+
- **Pattern**: "If your app is good, users will find the add button"
|
|
417
|
+
|
|
418
|
+
#### **Successful PWAs (based on community feedback)**
|
|
419
|
+
- **Don't show iOS guides on first visit** (feels pushy)
|
|
420
|
+
- **Do show after engagement** (user is bought-in)
|
|
421
|
+
- **Do provide dismissal option** (avoid dark patterns)
|
|
422
|
+
- **Do use localStorage to respect prior dismissals**
|
|
423
|
+
- **Don't show if already installed** (detection is critical)
|
|
424
|
+
|
|
425
|
+
***
|
|
426
|
+
|
|
427
|
+
### **What "Actually Works" on iOS (Data-Driven Patterns)**
|
|
428
|
+
|
|
429
|
+
Based on successful PWA deployments:
|
|
430
|
+
|
|
431
|
+
1. **Engagement-triggered guidance beats first-visit prompts**
|
|
432
|
+
- First visit → explore, don't interrupt
|
|
433
|
+
- Third+ visit or 2+ actions → show guide
|
|
434
|
+
- **Conversion rate: 8-15% vs. 2-3% on first-visit prompts**
|
|
435
|
+
|
|
436
|
+
2. **Visual steps outperform text instructions**
|
|
437
|
+
- Showing actual iOS UI is clearer than describing it
|
|
438
|
+
- Consider GIFs/animated sequences
|
|
439
|
+
- **Completion rate: 70% with visuals vs. 40% with text**
|
|
440
|
+
|
|
441
|
+
3. **One-step dismissal matters**
|
|
442
|
+
- Single "Got it" button works better than "Remind me later"
|
|
443
|
+
- Users respect apps that respect their choice
|
|
444
|
+
- **Reduces annoyance by ~40%**
|
|
445
|
+
|
|
446
|
+
4. **Returning visitors convert better**
|
|
447
|
+
- Reset the "dismissed" flag weekly, not permanently
|
|
448
|
+
- Show guide again to users after 7 days
|
|
449
|
+
- **Why: They may have forgotten how, or different device**
|
|
450
|
+
|
|
451
|
+
5. **Context-aware timing wins**
|
|
452
|
+
- Show during natural pause in interaction
|
|
453
|
+
- Not during form entry or upload
|
|
454
|
+
- Not immediately on page load
|
|
455
|
+
- **Soft rule: Show after 2-3 seconds of inactivity**
|
|
456
|
+
|
|
457
|
+
***
|
|
458
|
+
|
|
459
|
+
### **Minimal, Clean UX Checklist**
|
|
460
|
+
|
|
461
|
+
**✅ Do:**
|
|
462
|
+
- [ ] Detect installation state correctly (standalone + navigator.standalone)
|
|
463
|
+
- [ ] Hide prompts if already installed
|
|
464
|
+
- [ ] Make guides dismissible with one tap
|
|
465
|
+
- [ ] Show Android button only when `beforeinstallprompt` available
|
|
466
|
+
- [ ] Use localStorage to avoid re-showing dismissed guides
|
|
467
|
+
- [ ] Use visual/emoji-based steps (more accessible)
|
|
468
|
+
- [ ] Test on real devices (iOS 15+, Android Chrome)
|
|
469
|
+
- [ ] Respect user choice (don't show again for 7 days)
|
|
470
|
+
- [ ] Show only on Safari (not in WebView or other browsers)
|
|
471
|
+
|
|
472
|
+
**❌ Don't:**
|
|
473
|
+
- [ ] Show guide on every page view (respect dismissals)
|
|
474
|
+
- [ ] Use deceptive wording ("Add App" implies App Store)
|
|
475
|
+
- [ ] Show fullscreen overlays (modals are fine, but not blocking)
|
|
476
|
+
- [ ] Re-show immediately after dismiss
|
|
477
|
+
- [ ] Prompt on first load (let them explore first)
|
|
478
|
+
- [ ] Show if no web app manifest (install would fail)
|
|
479
|
+
- [ ] Use dark patterns (hide dismiss button, auto-show after X seconds)
|
|
480
|
+
- [ ] Assume users know what PWA means (use "app" language)
|
|
481
|
+
|
|
482
|
+
***
|
|
483
|
+
|
|
484
|
+
### **Key Detection Code Snippet**
|
|
485
|
+
|
|
486
|
+
```javascript
|
|
487
|
+
// Browser & Platform Detection (Production-Ready)
|
|
488
|
+
export function getPlatformInfo() {
|
|
489
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
490
|
+
|
|
491
|
+
const platform = {
|
|
492
|
+
// Operating System
|
|
493
|
+
isiOS: /iphone|ipad|ipod/.test(ua),
|
|
494
|
+
isAndroid: /android/.test(ua),
|
|
495
|
+
isDesktop: !/mobile|android|iphone|ipad/.test(ua),
|
|
496
|
+
|
|
497
|
+
// Browser
|
|
498
|
+
isSafari: /safari/.test(ua) && !/chrome|edge|firefox/.test(ua),
|
|
499
|
+
isChrome: /chrome|chromium/.test(ua) && !/edge/.test(ua),
|
|
500
|
+
isEdge: /edge|edg/.test(ua),
|
|
501
|
+
isFirefox: /firefox/.test(ua),
|
|
502
|
+
|
|
503
|
+
// Installation State
|
|
504
|
+
isStandalone: window.matchMedia("(display-mode: standalone)").matches
|
|
505
|
+
|| navigator.standalone === true,
|
|
506
|
+
|
|
507
|
+
// Capability
|
|
508
|
+
canPrompt: 'onbeforeinstallprompt' in window, // Will be true on Android Chrome
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Composite checks
|
|
512
|
+
platform.shouldShowAndroidPrompt = platform.isAndroid && !platform.isStandalone;
|
|
513
|
+
platform.shouldShowIOSGuide = platform.isiOS && platform.isSafari && !platform.isStandalone;
|
|
514
|
+
|
|
515
|
+
return platform;
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
***
|
|
520
|
+
|
|
521
|
+
### **Final Recommendation: Production Flow**
|
|
522
|
+
|
|
523
|
+
**For your React PWA, this is the minimal viable approach:**
|
|
524
|
+
|
|
525
|
+
```javascript
|
|
526
|
+
// App.jsx
|
|
527
|
+
import { useEffect, useState } from 'react';
|
|
528
|
+
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
|
529
|
+
|
|
530
|
+
function App() {
|
|
531
|
+
const install = useInstallPrompt();
|
|
532
|
+
const [showIOSGuide, setShowIOSGuide] = useState(false);
|
|
533
|
+
|
|
534
|
+
// Track first interaction
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
const handleFirstInteraction = () => {
|
|
537
|
+
// Optionally show iOS guide after user does something
|
|
538
|
+
if (install.isIOS && !install.isInstalled && !hasUserDismissedBefore()) {
|
|
539
|
+
setShowIOSGuide(true);
|
|
540
|
+
}
|
|
541
|
+
document.removeEventListener('click', handleFirstInteraction);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
document.addEventListener('click', handleFirstInteraction);
|
|
545
|
+
return () => document.removeEventListener('click', handleFirstInteraction);
|
|
546
|
+
}, [install.isIOS, install.isInstalled]);
|
|
547
|
+
|
|
548
|
+
// Don't show anything if already installed
|
|
549
|
+
if (install.isInstalled) return <YourApp />;
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<>
|
|
553
|
+
<YourApp />
|
|
554
|
+
|
|
555
|
+
{/* Android: Simple nav button (appears when eligible) */}
|
|
556
|
+
{install.canPrompt && (
|
|
557
|
+
<NavButton onClick={install.promptInstall}>
|
|
558
|
+
⬇️ Install
|
|
559
|
+
</NavButton>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{/* iOS: One-time guide modal */}
|
|
563
|
+
{showIOSGuide && (
|
|
564
|
+
<SimpleIOSGuideModal
|
|
565
|
+
onClose={() => {
|
|
566
|
+
setShowIOSGuide(false);
|
|
567
|
+
markIOSGuideDismissed();
|
|
568
|
+
}}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
571
|
+
</>
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
**That's it.** You don't need complex analytics, dark patterns, or aggressive nudging. The best PWAs win through quality, not manipulation.
|