@djangocfg/layouts 2.1.35 → 2.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/layouts/AppLayout/BaseApp.tsx +31 -25
- package/src/layouts/shared/types.ts +36 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
- package/src/snippets/PWA/@docs/research.md +576 -0
- package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
- package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
- package/src/snippets/PWA/@refactoring/README.md +204 -0
- package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
- package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
- package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
- package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
- package/src/snippets/PWA/@refactoring2/README.md +85 -0
- package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
- package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
- package/src/snippets/PWA/README.md +387 -0
- package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
- package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
- package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
- package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
- package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
- package/src/snippets/PWA/config.ts +20 -0
- package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
- package/src/snippets/PWA/context/InstallContext.tsx +118 -0
- package/src/snippets/PWA/context/PushContext.tsx +156 -0
- package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
- package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
- package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
- package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
- package/src/snippets/PWA/index.ts +95 -0
- package/src/snippets/PWA/types/components.ts +101 -0
- package/src/snippets/PWA/types/index.ts +26 -0
- package/src/snippets/PWA/types/install.ts +38 -0
- package/src/snippets/PWA/types/platform.ts +29 -0
- package/src/snippets/PWA/types/push.ts +21 -0
- package/src/snippets/PWA/utils/localStorage.ts +203 -0
- package/src/snippets/PWA/utils/logger.ts +149 -0
- package/src/snippets/PWA/utils/platform.ts +151 -0
- package/src/snippets/PWA/utils/vapid.ts +226 -0
- package/src/snippets/index.ts +30 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# PWA Install (Simplified)
|
|
2
|
+
|
|
3
|
+
Ultra-simple PWA installation for Cmdop. No tracking, no metrics, no complexity — just install.
|
|
4
|
+
|
|
5
|
+
## Quick Start (3 lines)
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
// app/layout.tsx
|
|
9
|
+
import { InstallProvider, A2HSHint } from '@/app/_snippets/PwaInstall';
|
|
10
|
+
import { settings } from '@core/settings';
|
|
11
|
+
|
|
12
|
+
export default function RootLayout({ children }) {
|
|
13
|
+
return (
|
|
14
|
+
<html>
|
|
15
|
+
<body>
|
|
16
|
+
<InstallProvider>
|
|
17
|
+
{children}
|
|
18
|
+
<A2HSHint
|
|
19
|
+
resetAfterDays={3}
|
|
20
|
+
logo={settings.app.icons.logo192}
|
|
21
|
+
/>
|
|
22
|
+
</InstallProvider>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Done. That's it.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## What You Get
|
|
34
|
+
|
|
35
|
+
✅ **Unified UX** → Same hint position (bottom) for both iOS & Android
|
|
36
|
+
✅ **iOS Safari** → Click hint → Opens visual step-by-step guide
|
|
37
|
+
✅ **Android Chrome** → Click hint → Native install prompt
|
|
38
|
+
✅ **Visual guide** → Adaptive (drawer on mobile, modal on desktop)
|
|
39
|
+
✅ **Zero config** → Works out of the box
|
|
40
|
+
✅ **Smart reset** → Re-appears after 3 days (user gets second chance)
|
|
41
|
+
✅ **No spam** → Dismissible, respects user choice
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### 1. `<InstallProvider>`
|
|
48
|
+
|
|
49
|
+
Wrap your app once:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<InstallProvider>
|
|
53
|
+
{children}
|
|
54
|
+
</InstallProvider>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. `<A2HSHint />`
|
|
58
|
+
|
|
59
|
+
**Unified install hint for iOS & Android** (auto-shows, dismissible):
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
<A2HSHint />
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Behavior (iOS Safari):**
|
|
66
|
+
- Shows after 3 seconds
|
|
67
|
+
- Text: "Keep terminal with you → Tap to learn how"
|
|
68
|
+
- Click → Opens visual guide (adaptive: drawer on mobile, modal on desktop)
|
|
69
|
+
- Dismissible (saved to localStorage)
|
|
70
|
+
- **Auto-resets after 3 days**
|
|
71
|
+
|
|
72
|
+
**Behavior (Android Chrome):**
|
|
73
|
+
- Shows after 3 seconds
|
|
74
|
+
- Text: "Install Cmdop → Tap to install"
|
|
75
|
+
- Click → Triggers native install prompt
|
|
76
|
+
- Dismissible (saved to localStorage)
|
|
77
|
+
- **Auto-resets after 3 days**
|
|
78
|
+
|
|
79
|
+
**Unified:**
|
|
80
|
+
- ✅ Same position (bottom)
|
|
81
|
+
- ✅ Same style (inline hint)
|
|
82
|
+
- ✅ Same interaction (tap to action)
|
|
83
|
+
- ✅ Platform-specific content
|
|
84
|
+
|
|
85
|
+
**Props (optional):**
|
|
86
|
+
```tsx
|
|
87
|
+
<A2HSHint
|
|
88
|
+
resetAfterDays={3} // Default: 3 days (set to null for never)
|
|
89
|
+
delayMs={3000} // Default: 3 seconds
|
|
90
|
+
forceShow={isDevelopment} // Show on ANY browser (for dev testing)
|
|
91
|
+
logo={settings.app.icons.logo192} // App logo URL (fallback: Share icon)
|
|
92
|
+
/>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3. `<InstallButton />` (Optional)
|
|
96
|
+
|
|
97
|
+
**Standalone install button** (if you want button in header instead of bottom hint):
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// In header
|
|
101
|
+
<InstallButton
|
|
102
|
+
text="Install"
|
|
103
|
+
className="ml-auto"
|
|
104
|
+
forceShow={isDevelopment}
|
|
105
|
+
/>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Behavior:**
|
|
109
|
+
- Only Android Chrome (when `beforeinstallprompt` fires)
|
|
110
|
+
- Auto-hides when installed
|
|
111
|
+
- Triggers native install prompt
|
|
112
|
+
- Loading state included
|
|
113
|
+
|
|
114
|
+
**Note:** You don't need this if using `<A2HSHint />` — it already handles Android!
|
|
115
|
+
|
|
116
|
+
### 4. `useInstall()` hook
|
|
117
|
+
|
|
118
|
+
Access install state anywhere:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { useInstall } from '@/app/_snippets/PwaInstall';
|
|
122
|
+
|
|
123
|
+
function Header() {
|
|
124
|
+
const { isIOS, isInstalled, canPrompt, install } = useInstall();
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<header>
|
|
128
|
+
{canPrompt && <button onClick={install}>Install</button>}
|
|
129
|
+
{isInstalled && <span>✓ Installed</span>}
|
|
130
|
+
</header>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Returns:**
|
|
136
|
+
```ts
|
|
137
|
+
{
|
|
138
|
+
isIOS: boolean;
|
|
139
|
+
isAndroid: boolean;
|
|
140
|
+
isSafari: boolean; // Real Safari (not Chromium browsers like Arc)
|
|
141
|
+
isChrome: boolean; // Any Chromium browser
|
|
142
|
+
isInstalled: boolean;
|
|
143
|
+
canPrompt: boolean; // Android only
|
|
144
|
+
install: () => Promise<'accepted' | 'dismissed' | null>;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Platform Detection:**
|
|
149
|
+
Uses `useBrowserDetect` and `useDeviceDetect` from `@djangocfg/ui-nextjs` for accurate cross-platform detection.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Complete Example (Cmdop)
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
// app/layout.tsx
|
|
157
|
+
import { InstallProvider, A2HSHint } from '@/app/_snippets/PwaInstall';
|
|
158
|
+
import { settings } from '@core/settings';
|
|
159
|
+
|
|
160
|
+
export default function RootLayout({ children }) {
|
|
161
|
+
return (
|
|
162
|
+
<html>
|
|
163
|
+
<body>
|
|
164
|
+
<InstallProvider>
|
|
165
|
+
<Header />
|
|
166
|
+
{children}
|
|
167
|
+
<A2HSHint
|
|
168
|
+
resetAfterDays={3}
|
|
169
|
+
logo={settings.app.icons.logo192}
|
|
170
|
+
/>
|
|
171
|
+
</InstallProvider>
|
|
172
|
+
</body>
|
|
173
|
+
</html>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// components/Header.tsx
|
|
180
|
+
import { InstallButton } from '@/app/_snippets/PwaInstall';
|
|
181
|
+
|
|
182
|
+
export function Header() {
|
|
183
|
+
return (
|
|
184
|
+
<header className="flex items-center justify-between p-4">
|
|
185
|
+
<h1>Cmdop</h1>
|
|
186
|
+
<InstallButton /> {/* Auto-shows on Android */}
|
|
187
|
+
</header>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## How It Works
|
|
195
|
+
|
|
196
|
+
### Android Chrome
|
|
197
|
+
|
|
198
|
+
1. Browser fires `beforeinstallprompt`
|
|
199
|
+
2. We capture it
|
|
200
|
+
3. Wait 3 seconds (engagement)
|
|
201
|
+
4. Show hint at bottom: "Install Cmdop → Tap to install"
|
|
202
|
+
5. User taps → Native install prompt
|
|
203
|
+
6. User accepts → App installed
|
|
204
|
+
7. Hint auto-dismisses
|
|
205
|
+
|
|
206
|
+
### iOS Safari
|
|
207
|
+
|
|
208
|
+
1. Detect iOS + Safari
|
|
209
|
+
2. Check if installed (`standalone` mode)
|
|
210
|
+
3. Check if dismissed recently (localStorage with timestamp)
|
|
211
|
+
4. If dismissed more than 3 days ago → show again
|
|
212
|
+
5. Wait 3 seconds (engagement)
|
|
213
|
+
6. Show clickable hint at bottom
|
|
214
|
+
7. **User taps hint** → Opens visual guide (drawer on mobile, modal on desktop)
|
|
215
|
+
8. **Visual guide shows 3 steps:**
|
|
216
|
+
- Tap Share button
|
|
217
|
+
- Scroll & tap "Add to Home Screen"
|
|
218
|
+
- Tap "Add" to confirm
|
|
219
|
+
9. User dismisses → Save timestamp
|
|
220
|
+
10. After 3 days → Reset, show again
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Advanced Usage
|
|
225
|
+
|
|
226
|
+
### Configure Reset Behavior
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
// Default: Reset after 3 days
|
|
230
|
+
<A2HSHint />
|
|
231
|
+
|
|
232
|
+
// Never reset (show once forever)
|
|
233
|
+
<A2HSHint resetAfterDays={null} />
|
|
234
|
+
|
|
235
|
+
// Reset after 7 days
|
|
236
|
+
<A2HSHint resetAfterDays={7} />
|
|
237
|
+
|
|
238
|
+
// Show immediately (no delay)
|
|
239
|
+
<A2HSHint delayMs={0} />
|
|
240
|
+
|
|
241
|
+
// Force show for dev testing (shows on ANY browser, ignores localStorage)
|
|
242
|
+
<A2HSHint forceShow={isDevelopment} />
|
|
243
|
+
|
|
244
|
+
// Production setup
|
|
245
|
+
<A2HSHint
|
|
246
|
+
resetAfterDays={3}
|
|
247
|
+
delayMs={3000}
|
|
248
|
+
forceShow={settings.pwa.forceShowInDev}
|
|
249
|
+
/>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Important: `forceShow` behavior**
|
|
253
|
+
- ✅ Shows on ANY browser (not just iOS Safari)
|
|
254
|
+
- ✅ Ignores localStorage (dismissed state)
|
|
255
|
+
- ✅ Doesn't save dismiss to localStorage
|
|
256
|
+
- ✅ Reappears on every refresh (perfect for dev testing)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Custom Install Flow
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { useInstall } from '@/app/_snippets/PwaInstall';
|
|
263
|
+
|
|
264
|
+
function CustomFlow() {
|
|
265
|
+
const { isIOS, canPrompt, install } = useInstall();
|
|
266
|
+
|
|
267
|
+
const handleInstall = async () => {
|
|
268
|
+
if (canPrompt) {
|
|
269
|
+
// Android: trigger native prompt
|
|
270
|
+
const outcome = await install();
|
|
271
|
+
console.log('Install outcome:', outcome);
|
|
272
|
+
} else if (isIOS) {
|
|
273
|
+
// iOS: show custom modal or guide
|
|
274
|
+
alert('Tap Share → Add to Home Screen');
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return <button onClick={handleInstall}>Install App</button>;
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Check PWA State
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import { useInstall } from '@/app/_snippets/PwaInstall';
|
|
286
|
+
|
|
287
|
+
function AppStatus() {
|
|
288
|
+
const { isInstalled } = useInstall();
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div>
|
|
292
|
+
{isInstalled ? (
|
|
293
|
+
<p>✓ Running as PWA</p>
|
|
294
|
+
) : (
|
|
295
|
+
<p>Running in browser</p>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Why This Approach?
|
|
305
|
+
|
|
306
|
+
### Click-to-Guide Pattern
|
|
307
|
+
|
|
308
|
+
Research shows **visual steps outperform text-only instructions**:
|
|
309
|
+
- 📊 **70% completion rate** with visual guide
|
|
310
|
+
- 📊 **40% completion rate** with text-only
|
|
311
|
+
|
|
312
|
+
**Our approach:**
|
|
313
|
+
1. **Minimal hint** — doesn't overwhelm ("Tap to learn how")
|
|
314
|
+
2. **Click → Visual guide** — for those who want details
|
|
315
|
+
3. **Adaptive UI** — drawer on mobile, modal on desktop
|
|
316
|
+
4. **Device-specific** — iOS Safari gets iOS-specific steps
|
|
317
|
+
|
|
318
|
+
### Why Simplified?
|
|
319
|
+
|
|
320
|
+
The old version had:
|
|
321
|
+
- ❌ Engagement tracking (`actions`, `timeSpent`, `visitCount`)
|
|
322
|
+
- ❌ Complex metrics in localStorage
|
|
323
|
+
- ❌ Too many props (`delayMs`, `resetDays`, `engagementThreshold`)
|
|
324
|
+
- ❌ Multiple components for same job
|
|
325
|
+
- ❌ Over-engineered for Cmdop
|
|
326
|
+
|
|
327
|
+
The new version:
|
|
328
|
+
- ✅ One provider, two components, one hook
|
|
329
|
+
- ✅ Click-to-guide (minimal hint + visual guide on demand)
|
|
330
|
+
- ✅ Zero config
|
|
331
|
+
- ✅ No tracking
|
|
332
|
+
- ✅ Simple localStorage (only dismiss state)
|
|
333
|
+
- ✅ Works out of the box
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Troubleshooting
|
|
338
|
+
|
|
339
|
+
**iOS hint not showing?**
|
|
340
|
+
- Check if iOS Safari (not Chrome on iOS)
|
|
341
|
+
- Check if already installed
|
|
342
|
+
- Check if dismissed recently:
|
|
343
|
+
```js
|
|
344
|
+
const dismissed = localStorage.getItem('cmdop-a2hs-dismissed');
|
|
345
|
+
if (dismissed) {
|
|
346
|
+
const days = (Date.now() - parseInt(dismissed)) / (1000 * 60 * 60 * 24);
|
|
347
|
+
console.log('Dismissed', days.toFixed(1), 'days ago');
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
- Force reset: `localStorage.removeItem('cmdop-a2hs-dismissed')`
|
|
351
|
+
- **Hint reappears automatically after 3 days**
|
|
352
|
+
|
|
353
|
+
**Android button not showing?**
|
|
354
|
+
- Check PWA manifest (`/manifest.json`)
|
|
355
|
+
- Check service worker
|
|
356
|
+
- Check HTTPS (required for PWA)
|
|
357
|
+
- Open DevTools → Application → Manifest
|
|
358
|
+
|
|
359
|
+
**Already installed but still showing?**
|
|
360
|
+
- Clear localStorage: `localStorage.clear()`
|
|
361
|
+
- Reload page
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Browser Support
|
|
366
|
+
|
|
367
|
+
| Platform | Browser | Support |
|
|
368
|
+
|----------|---------|---------|
|
|
369
|
+
| iOS | Safari | ✅ Inline hint |
|
|
370
|
+
| iOS | Chrome/Firefox | ❌ No PWA support |
|
|
371
|
+
| Android | Chrome | ✅ Native prompt |
|
|
372
|
+
| Android | Firefox | ⚠️ Manual only |
|
|
373
|
+
| Desktop | Chrome/Edge | ✅ Native prompt |
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## That's It
|
|
378
|
+
|
|
379
|
+
Three exports:
|
|
380
|
+
1. `<InstallProvider>` — wrap your app
|
|
381
|
+
2. `<A2HSHint />` — iOS hint
|
|
382
|
+
3. `<InstallButton />` — Android button
|
|
383
|
+
|
|
384
|
+
One hook:
|
|
385
|
+
- `useInstall()` — access state
|
|
386
|
+
|
|
387
|
+
Zero config. Done.
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
* Optionally shows push notification prompt after PWA installation
|
|
14
|
+
* by providing pushNotifications.vapidPublicKey
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useState, useEffect } from 'react';
|
|
18
|
+
import { Share, X, ChevronRight, Download } from 'lucide-react';
|
|
19
|
+
import { Button } from '@djangocfg/ui-nextjs';
|
|
20
|
+
import { cn } from '@djangocfg/ui-nextjs/lib';
|
|
21
|
+
|
|
22
|
+
import { useInstall } from '../context/InstallContext';
|
|
23
|
+
import { IOSGuide } from './IOSGuide';
|
|
24
|
+
import { PushPrompt } from './PushPrompt';
|
|
25
|
+
import { pwaLogger } from '../utils/logger';
|
|
26
|
+
import { markA2HSDismissed, isA2HSDismissedRecently } from '../utils/localStorage';
|
|
27
|
+
import type { PushNotificationOptions } from '../types';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_RESET_DAYS = 3;
|
|
30
|
+
|
|
31
|
+
interface A2HSHintProps {
|
|
32
|
+
/**
|
|
33
|
+
* Additional class names for the container
|
|
34
|
+
*/
|
|
35
|
+
className?: string;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Number of days before re-showing dismissed hint
|
|
39
|
+
* @default 3
|
|
40
|
+
* Set to null to never reset (show once forever)
|
|
41
|
+
*/
|
|
42
|
+
resetAfterDays?: number | null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Delay before showing hint (ms)
|
|
46
|
+
* @default 3000
|
|
47
|
+
*/
|
|
48
|
+
delayMs?: number;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Force show on ANY browser (ignores platform detection)
|
|
52
|
+
* Useful for testing on desktop during development
|
|
53
|
+
* @default false
|
|
54
|
+
*/
|
|
55
|
+
forceShow?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* App logo URL to display in hint
|
|
59
|
+
* If not provided, uses Share icon
|
|
60
|
+
*/
|
|
61
|
+
logo?: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Enable push notifications prompt after PWA install
|
|
65
|
+
* Provide VAPID public key to enable
|
|
66
|
+
*/
|
|
67
|
+
pushNotifications?: PushNotificationOptions & {
|
|
68
|
+
/**
|
|
69
|
+
* Delay before showing push prompt after PWA install (ms)
|
|
70
|
+
* @default 5000
|
|
71
|
+
*/
|
|
72
|
+
delayMs?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Number of days before re-showing dismissed push prompt
|
|
75
|
+
* @default 7
|
|
76
|
+
*/
|
|
77
|
+
resetAfterDays?: number;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function A2HSHint({
|
|
82
|
+
className,
|
|
83
|
+
resetAfterDays = DEFAULT_RESET_DAYS,
|
|
84
|
+
delayMs = 3000,
|
|
85
|
+
forceShow = false,
|
|
86
|
+
logo,
|
|
87
|
+
pushNotifications
|
|
88
|
+
}: A2HSHintProps = {}) {
|
|
89
|
+
const { isIOS, isSafari, isInstalled, canPrompt, install } = useInstall();
|
|
90
|
+
const [show, setShow] = useState(false);
|
|
91
|
+
const [showGuide, setShowGuide] = useState(false);
|
|
92
|
+
const [installing, setInstalling] = useState(false);
|
|
93
|
+
|
|
94
|
+
// Determine if should show hint
|
|
95
|
+
const shouldShow = forceShow || (!isInstalled && ((isIOS && isSafari) || canPrompt));
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
// Only show on iOS Safari or Android Chrome (unless forceShow for dev testing)
|
|
99
|
+
if (!shouldShow) return;
|
|
100
|
+
|
|
101
|
+
// Check if previously dismissed (skip localStorage check if forceShow)
|
|
102
|
+
if (!forceShow && typeof window !== 'undefined') {
|
|
103
|
+
// If resetAfterDays is null, never reset (check with very large number)
|
|
104
|
+
if (resetAfterDays === null) {
|
|
105
|
+
if (isA2HSDismissedRecently(Number.MAX_SAFE_INTEGER)) {
|
|
106
|
+
return; // Dismissed forever
|
|
107
|
+
}
|
|
108
|
+
} else if (isA2HSDismissedRecently(resetAfterDays)) {
|
|
109
|
+
return; // Still within reset period
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Show after delay (user is already engaged)
|
|
114
|
+
const timer = setTimeout(() => setShow(true), delayMs);
|
|
115
|
+
return () => clearTimeout(timer);
|
|
116
|
+
}, [shouldShow, resetAfterDays, delayMs, forceShow]);
|
|
117
|
+
|
|
118
|
+
const handleDismiss = () => {
|
|
119
|
+
setShow(false);
|
|
120
|
+
// Don't save to localStorage if forceShow (dev testing mode)
|
|
121
|
+
if (!forceShow) {
|
|
122
|
+
markA2HSDismissed();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleGuideDismiss = () => {
|
|
127
|
+
setShowGuide(false);
|
|
128
|
+
// When guide is dismissed, also dismiss the hint
|
|
129
|
+
handleDismiss();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleClick = async () => {
|
|
133
|
+
// forceShow (dev mode) or iOS: Open visual guide
|
|
134
|
+
if (forceShow || (isIOS && isSafari)) {
|
|
135
|
+
setShowGuide(true);
|
|
136
|
+
} else if (canPrompt) {
|
|
137
|
+
// Android: Trigger native install prompt
|
|
138
|
+
setInstalling(true);
|
|
139
|
+
try {
|
|
140
|
+
await install();
|
|
141
|
+
// If install succeeds, dismiss hint
|
|
142
|
+
handleDismiss();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
pwaLogger.error('[A2HSHint] Install error:', error);
|
|
145
|
+
} finally {
|
|
146
|
+
setInstalling(false);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (!show) return null;
|
|
152
|
+
|
|
153
|
+
// Platform-specific content
|
|
154
|
+
const isIOSPlatform = isIOS && isSafari;
|
|
155
|
+
const title = isIOSPlatform ? 'Keep terminal with you' : 'Install Cmdop';
|
|
156
|
+
const subtitle = isIOSPlatform ? (
|
|
157
|
+
<>
|
|
158
|
+
Tap to learn how <ChevronRight className="w-3 h-3" />
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
Tap to install <Download className="w-3 h-3" />
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<div className={cn(
|
|
169
|
+
"fixed bottom-4 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 duration-300",
|
|
170
|
+
className
|
|
171
|
+
)}>
|
|
172
|
+
<div className="w-full bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-lg">
|
|
173
|
+
<div className="flex items-center gap-3">
|
|
174
|
+
{/* App logo or icon */}
|
|
175
|
+
<div className="flex-shrink-0">
|
|
176
|
+
{logo ? (
|
|
177
|
+
<img src={logo} alt="App logo" className="w-10 h-10 rounded-lg" />
|
|
178
|
+
) : (
|
|
179
|
+
<Share className="w-5 h-5 text-blue-400" />
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Content */}
|
|
184
|
+
<div className="flex-1 min-w-0">
|
|
185
|
+
<p className="text-sm font-medium text-white mb-1">{title}</p>
|
|
186
|
+
<Button
|
|
187
|
+
onClick={handleClick}
|
|
188
|
+
loading={installing}
|
|
189
|
+
size="sm"
|
|
190
|
+
variant="ghost"
|
|
191
|
+
className="text-xs text-zinc-400 hover:text-zinc-300 p-0 h-auto font-normal flex items-center gap-1"
|
|
192
|
+
>
|
|
193
|
+
{subtitle}
|
|
194
|
+
</Button>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Close button */}
|
|
198
|
+
<Button
|
|
199
|
+
onClick={handleDismiss}
|
|
200
|
+
size="sm"
|
|
201
|
+
variant="ghost"
|
|
202
|
+
className="flex-shrink-0 p-1"
|
|
203
|
+
aria-label="Dismiss"
|
|
204
|
+
>
|
|
205
|
+
<X className="w-4 h-4" />
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* iOS or forceShow: Detailed guide modal - dismissing guide also dismisses hint */}
|
|
212
|
+
{(isIOSPlatform || forceShow) && <IOSGuide open={showGuide} onDismiss={handleGuideDismiss} />}
|
|
213
|
+
|
|
214
|
+
{/* Push Notifications Prompt - shown after PWA install if enabled */}
|
|
215
|
+
{pushNotifications?.vapidPublicKey && (
|
|
216
|
+
<PushPrompt
|
|
217
|
+
vapidPublicKey={pushNotifications.vapidPublicKey}
|
|
218
|
+
subscribeEndpoint={pushNotifications.subscribeEndpoint}
|
|
219
|
+
requirePWA={true}
|
|
220
|
+
delayMs={pushNotifications.delayMs || 5000}
|
|
221
|
+
resetAfterDays={pushNotifications.resetAfterDays || 7}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -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-nextjs/hooks';
|
|
14
|
+
|
|
15
|
+
import { IOSGuideModal } from './IOSGuideModal';
|
|
16
|
+
import { IOSGuideDrawer } from './IOSGuideDrawer';
|
|
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
|
+
}
|