@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,1321 @@
|
|
|
1
|
+
# Recommendations: How to Fix Remaining Issues
|
|
2
|
+
|
|
3
|
+
**Date**: December 2025
|
|
4
|
+
**Context**: Post-P0 Refactoring
|
|
5
|
+
|
|
6
|
+
This document provides **actionable recommendations** with **ready-to-use code** for fixing all remaining issues identified in [REMAINING-ISSUES.md](./REMAINING-ISSUES.md).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
### P1 (High Priority) - ~30 minutes total
|
|
13
|
+
1. [Fix Security Vulnerability](#1-fix-security-vulnerability-p1-1) (5 min)
|
|
14
|
+
2. [Fix Inconsistent Logging](#2-fix-inconsistent-logging-p1-2) (10 min)
|
|
15
|
+
3. [Centralize LocalStorage Keys](#3-centralize-localstorage-keys-p1-3) (15 min)
|
|
16
|
+
|
|
17
|
+
### P2 (Medium Priority) - ~4 hours total
|
|
18
|
+
4. [Handle Unused EngagementMetrics](#4-handle-unused-engagementmetrics-p2-1) (30 min)
|
|
19
|
+
5. [Simplify Context Composition](#5-simplify-context-composition-p2-2) (30 min)
|
|
20
|
+
6. [Add Server-Side Persistence](#6-add-server-side-persistence-p2-3) (1.5 hours)
|
|
21
|
+
7. [Improve Error Recovery](#7-improve-error-recovery-p2-4) (1 hour)
|
|
22
|
+
|
|
23
|
+
### P3 (Nice to Have) - Ongoing
|
|
24
|
+
8. [Add Testing](#8-add-testing-p3-1) (4-8 hours)
|
|
25
|
+
9. [Improve Accessibility](#9-improve-accessibility-p3-2) (2-3 hours)
|
|
26
|
+
10. [Enable TypeScript Strict Mode](#10-enable-typescript-strict-mode-p3-3) (2-4 hours)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## P1: High Priority Fixes
|
|
31
|
+
|
|
32
|
+
### 1. Fix Security Vulnerability (P1-1)
|
|
33
|
+
|
|
34
|
+
**Issue**: `config.ts` exposes `VAPID_PRIVATE_KEY` in frontend package
|
|
35
|
+
|
|
36
|
+
**File**: `src/snippets/PWA/config.ts`
|
|
37
|
+
|
|
38
|
+
**Current Code** (lines 7-11):
|
|
39
|
+
```typescript
|
|
40
|
+
// ❌ SECURITY VULNERABILITY
|
|
41
|
+
export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
|
|
42
|
+
export const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
|
43
|
+
export const VAPID_MAILTO = process.env.VAPID_MAILTO || '';
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**✅ Fixed Code**:
|
|
47
|
+
```typescript
|
|
48
|
+
/**
|
|
49
|
+
* PWA Configuration
|
|
50
|
+
*
|
|
51
|
+
* Centralized constants for PWA functionality.
|
|
52
|
+
*
|
|
53
|
+
* SECURITY NOTE:
|
|
54
|
+
* - VAPID_PRIVATE_KEY should NEVER be in frontend code
|
|
55
|
+
* - Use only in backend/API routes
|
|
56
|
+
* - Frontend only needs the public key
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
// Default VAPID public key (safe to expose)
|
|
60
|
+
export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
|
|
61
|
+
|
|
62
|
+
// NOTE: VAPID private key and mailto should only exist in:
|
|
63
|
+
// - Backend environment variables
|
|
64
|
+
// - API route handlers
|
|
65
|
+
// - Service worker generation scripts
|
|
66
|
+
// NEVER import or use private keys in frontend code
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Additional**: Add to `.env.example`:
|
|
70
|
+
```bash
|
|
71
|
+
# Frontend (PUBLIC - safe to expose)
|
|
72
|
+
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
|
|
73
|
+
|
|
74
|
+
# Backend only (PRIVATE - never expose)
|
|
75
|
+
VAPID_PRIVATE_KEY=your_private_key_here
|
|
76
|
+
VAPID_MAILTO=mailto:your-email@example.com
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Verification**:
|
|
80
|
+
```bash
|
|
81
|
+
# Ensure private key never bundled
|
|
82
|
+
npm run build
|
|
83
|
+
grep -r "VAPID_PRIVATE_KEY" .next/static # Should return nothing
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 2. Fix Inconsistent Logging (P1-2)
|
|
89
|
+
|
|
90
|
+
**Issue**: 3 files use `console.error` instead of `pwaLogger.error`
|
|
91
|
+
|
|
92
|
+
**Files**:
|
|
93
|
+
- `components/PushPrompt.tsx:106`
|
|
94
|
+
- `components/A2HSHint.tsx:150`
|
|
95
|
+
- `context/PushContext.tsx:114`
|
|
96
|
+
|
|
97
|
+
#### Fix 2.1: PushPrompt.tsx
|
|
98
|
+
|
|
99
|
+
**Current Code** (line 106):
|
|
100
|
+
```typescript
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('[PushPrompt] Enable failed:', error);
|
|
103
|
+
} finally {
|
|
104
|
+
setEnabling(false);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**✅ Fixed Code**:
|
|
109
|
+
```typescript
|
|
110
|
+
import { pwaLogger } from '../utils/logger';
|
|
111
|
+
|
|
112
|
+
// ... in handleEnable function
|
|
113
|
+
} catch (error) {
|
|
114
|
+
pwaLogger.error('[PushPrompt] Enable failed:', error);
|
|
115
|
+
} finally {
|
|
116
|
+
setEnabling(false);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Fix 2.2: A2HSHint.tsx
|
|
121
|
+
|
|
122
|
+
**Current Code** (line 150):
|
|
123
|
+
```typescript
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('[A2HSHint] Install error:', error);
|
|
126
|
+
} finally {
|
|
127
|
+
setInstalling(false);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**✅ Fixed Code**:
|
|
132
|
+
```typescript
|
|
133
|
+
import { pwaLogger } from '../utils/logger';
|
|
134
|
+
|
|
135
|
+
// ... in handleClick function
|
|
136
|
+
} catch (error) {
|
|
137
|
+
pwaLogger.error('[A2HSHint] Install error:', error);
|
|
138
|
+
} finally {
|
|
139
|
+
setInstalling(false);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### Fix 2.3: PushContext.tsx
|
|
144
|
+
|
|
145
|
+
**Current Code** (line 114):
|
|
146
|
+
```typescript
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error('Failed to send push:', error);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**✅ Fixed Code**:
|
|
154
|
+
```typescript
|
|
155
|
+
import { pwaLogger } from '../utils/logger';
|
|
156
|
+
|
|
157
|
+
// ... in sendPush function
|
|
158
|
+
} catch (error) {
|
|
159
|
+
pwaLogger.error('[PushContext] Failed to send push:', error);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Verification**: Search for remaining console calls:
|
|
165
|
+
```bash
|
|
166
|
+
grep -r "console\." src/snippets/PWA --include="*.ts" --include="*.tsx" | grep -v "\.md" | grep -v "example"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Should only return JSDoc examples, no actual code.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### 3. Centralize LocalStorage Keys (P1-3)
|
|
174
|
+
|
|
175
|
+
**Issue**: `DISMISSED_KEY` duplicated in A2HSHint and PushPrompt
|
|
176
|
+
|
|
177
|
+
**Files**:
|
|
178
|
+
- `components/A2HSHint.tsx:27`
|
|
179
|
+
- `components/PushPrompt.tsx:18`
|
|
180
|
+
|
|
181
|
+
**Current Code**:
|
|
182
|
+
```typescript
|
|
183
|
+
// A2HSHint.tsx
|
|
184
|
+
const DISMISSED_KEY = 'cmdop-a2hs-dismissed';
|
|
185
|
+
|
|
186
|
+
// PushPrompt.tsx
|
|
187
|
+
const DISMISSED_KEY = 'pwa-push-dismissed';
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### Fix 3.1: Update utils/localStorage.ts
|
|
191
|
+
|
|
192
|
+
**Add to existing STORAGE_KEYS** (after line 14):
|
|
193
|
+
```typescript
|
|
194
|
+
export const STORAGE_KEYS = {
|
|
195
|
+
IOS_GUIDE_DISMISSED: 'pwa_ios_guide_dismissed_at',
|
|
196
|
+
APP_INSTALLED: 'pwa_app_installed',
|
|
197
|
+
ENGAGEMENT: 'pwa_engagement_metrics',
|
|
198
|
+
// ✅ NEW: Add these keys
|
|
199
|
+
A2HS_DISMISSED: 'pwa_a2hs_dismissed_at',
|
|
200
|
+
PUSH_DISMISSED: 'pwa_push_dismissed_at',
|
|
201
|
+
} as const;
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Add helper functions** (at end of file):
|
|
205
|
+
```typescript
|
|
206
|
+
/**
|
|
207
|
+
* Check if A2HS hint was dismissed recently
|
|
208
|
+
* @param resetDays Number of days before re-showing (default: 3)
|
|
209
|
+
*/
|
|
210
|
+
export function isA2HSDismissedRecently(resetDays: number = 3): boolean {
|
|
211
|
+
return isDismissedRecently(resetDays, STORAGE_KEYS.A2HS_DISMISSED);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Mark A2HS hint as dismissed
|
|
216
|
+
*/
|
|
217
|
+
export function markA2HSDismissed(): void {
|
|
218
|
+
if (typeof window === 'undefined') return;
|
|
219
|
+
try {
|
|
220
|
+
localStorage.setItem(STORAGE_KEYS.A2HS_DISMISSED, Date.now().toString());
|
|
221
|
+
} catch {
|
|
222
|
+
// Fail silently
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if push prompt was dismissed recently
|
|
228
|
+
* @param resetDays Number of days before re-showing (default: 7)
|
|
229
|
+
*/
|
|
230
|
+
export function isPushDismissedRecently(resetDays: number = 7): boolean {
|
|
231
|
+
return isDismissedRecently(resetDays, STORAGE_KEYS.PUSH_DISMISSED);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Mark push prompt as dismissed
|
|
236
|
+
*/
|
|
237
|
+
export function markPushDismissed(): void {
|
|
238
|
+
if (typeof window === 'undefined') return;
|
|
239
|
+
try {
|
|
240
|
+
localStorage.setItem(STORAGE_KEYS.PUSH_DISMISSED, Date.now().toString());
|
|
241
|
+
} catch {
|
|
242
|
+
// Fail silently
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Helper: Check if a key was dismissed recently
|
|
248
|
+
*/
|
|
249
|
+
function isDismissedRecently(resetDays: number, key: string): boolean {
|
|
250
|
+
if (typeof window === 'undefined') return false;
|
|
251
|
+
try {
|
|
252
|
+
const dismissed = localStorage.getItem(key);
|
|
253
|
+
if (!dismissed) return false;
|
|
254
|
+
const dismissedAt = parseInt(dismissed, 10);
|
|
255
|
+
const daysSince = (Date.now() - dismissedAt) / (1000 * 60 * 60 * 24);
|
|
256
|
+
return daysSince < resetDays;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### Fix 3.2: Update A2HSHint.tsx
|
|
264
|
+
|
|
265
|
+
**Remove local constant** (delete line 27):
|
|
266
|
+
```typescript
|
|
267
|
+
// ❌ DELETE THIS
|
|
268
|
+
const DISMISSED_KEY = 'cmdop-a2hs-dismissed';
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Add import**:
|
|
272
|
+
```typescript
|
|
273
|
+
import { markA2HSDismissed, isA2HSDismissedRecently } from '../utils/localStorage';
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Replace dismissal logic** (lines 100-116):
|
|
277
|
+
```typescript
|
|
278
|
+
// ✅ REPLACE with
|
|
279
|
+
if (!forceShow && typeof window !== 'undefined') {
|
|
280
|
+
// If resetAfterDays is null, never reset
|
|
281
|
+
if (resetAfterDays === null) {
|
|
282
|
+
if (isA2HSDismissedRecently(Number.MAX_SAFE_INTEGER)) return;
|
|
283
|
+
} else if (isA2HSDismissedRecently(resetAfterDays)) {
|
|
284
|
+
return; // Still within reset period
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Replace handleDismiss** (lines 123-130):
|
|
290
|
+
```typescript
|
|
291
|
+
const handleDismiss = () => {
|
|
292
|
+
setShow(false);
|
|
293
|
+
if (!forceShow) {
|
|
294
|
+
markA2HSDismissed();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
#### Fix 3.3: Update PushPrompt.tsx
|
|
300
|
+
|
|
301
|
+
**Remove local constant** (delete line 18):
|
|
302
|
+
```typescript
|
|
303
|
+
// ❌ DELETE THIS
|
|
304
|
+
const DISMISSED_KEY = 'pwa-push-dismissed';
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Add import**:
|
|
308
|
+
```typescript
|
|
309
|
+
import { markPushDismissed, isPushDismissedRecently } from '../utils/localStorage';
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Replace dismissal logic** (lines 79-90):
|
|
313
|
+
```typescript
|
|
314
|
+
// ✅ REPLACE with
|
|
315
|
+
if (typeof window !== 'undefined') {
|
|
316
|
+
if (isPushDismissedRecently(resetAfterDays)) {
|
|
317
|
+
return; // Still within reset period
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Replace handleDismiss** (lines 112-118):
|
|
323
|
+
```typescript
|
|
324
|
+
const handleDismiss = () => {
|
|
325
|
+
setShow(false);
|
|
326
|
+
markPushDismissed();
|
|
327
|
+
onDismissed?.();
|
|
328
|
+
};
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### Fix 3.4: Export new functions in index.ts
|
|
332
|
+
|
|
333
|
+
**Add to exports** (in index.ts):
|
|
334
|
+
```typescript
|
|
335
|
+
export {
|
|
336
|
+
// ... existing exports
|
|
337
|
+
markA2HSDismissed,
|
|
338
|
+
markPushDismissed,
|
|
339
|
+
isA2HSDismissedRecently,
|
|
340
|
+
isPushDismissedRecently,
|
|
341
|
+
} from './utils/localStorage';
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Verification**:
|
|
345
|
+
```bash
|
|
346
|
+
# Ensure no hardcoded localStorage keys
|
|
347
|
+
grep -r "localStorage\\..*Item\\(" src/snippets/PWA/components
|
|
348
|
+
# Should not return any results
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## P2: Medium Priority Fixes
|
|
354
|
+
|
|
355
|
+
### 4. Handle Unused EngagementMetrics (P2-1)
|
|
356
|
+
|
|
357
|
+
**Issue**: 74 lines of engagement tracking code that's never used
|
|
358
|
+
|
|
359
|
+
**Decision Required**: Remove or Use?
|
|
360
|
+
|
|
361
|
+
#### Option A: Remove It (Recommended)
|
|
362
|
+
|
|
363
|
+
**If**: No plan to use within 2 sprints
|
|
364
|
+
|
|
365
|
+
**Step 1**: Remove from `utils/localStorage.ts` (lines 132-205):
|
|
366
|
+
```typescript
|
|
367
|
+
// ❌ DELETE these functions:
|
|
368
|
+
// - getEngagementMetrics
|
|
369
|
+
// - saveEngagementMetrics
|
|
370
|
+
// - trackAction
|
|
371
|
+
// - trackTimeSpent
|
|
372
|
+
// - updateVisit
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Step 2**: Remove from `types/components.ts`:
|
|
376
|
+
```typescript
|
|
377
|
+
// ❌ DELETE this interface
|
|
378
|
+
export interface EngagementMetrics {
|
|
379
|
+
actions: number;
|
|
380
|
+
timeSpent: number;
|
|
381
|
+
lastVisit: number | null;
|
|
382
|
+
visitCount: number;
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Step 3**: Update `STORAGE_KEYS`:
|
|
387
|
+
```typescript
|
|
388
|
+
export const STORAGE_KEYS = {
|
|
389
|
+
IOS_GUIDE_DISMISSED: 'pwa_ios_guide_dismissed_at',
|
|
390
|
+
APP_INSTALLED: 'pwa_app_installed',
|
|
391
|
+
// ❌ DELETE this line
|
|
392
|
+
// ENGAGEMENT: 'pwa_engagement_metrics',
|
|
393
|
+
} as const;
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**Step 4**: Update `clearAllPWAData()` (line 210):
|
|
397
|
+
```typescript
|
|
398
|
+
export function clearAllPWAData(): void {
|
|
399
|
+
if (typeof window === 'undefined') return;
|
|
400
|
+
try {
|
|
401
|
+
localStorage.removeItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED);
|
|
402
|
+
localStorage.removeItem(STORAGE_KEYS.APP_INSTALLED);
|
|
403
|
+
// ❌ DELETE this line
|
|
404
|
+
// localStorage.removeItem(STORAGE_KEYS.ENGAGEMENT);
|
|
405
|
+
} catch {
|
|
406
|
+
// Fail silently
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
#### Option B: Actually Use It
|
|
412
|
+
|
|
413
|
+
**If**: You want engagement-based install prompts
|
|
414
|
+
|
|
415
|
+
**Step 1**: Create hook `hooks/useEngagement.ts`:
|
|
416
|
+
```typescript
|
|
417
|
+
'use client';
|
|
418
|
+
|
|
419
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
420
|
+
import { getEngagementMetrics, saveEngagementMetrics, type EngagementMetrics } from '../utils/localStorage';
|
|
421
|
+
|
|
422
|
+
export function useEngagement() {
|
|
423
|
+
const [metrics, setMetrics] = useState<EngagementMetrics>(getEngagementMetrics);
|
|
424
|
+
|
|
425
|
+
const trackAction = useCallback(() => {
|
|
426
|
+
setMetrics(prev => {
|
|
427
|
+
const updated = { ...prev, actions: prev.actions + 1 };
|
|
428
|
+
saveEngagementMetrics(updated);
|
|
429
|
+
return updated;
|
|
430
|
+
});
|
|
431
|
+
}, []);
|
|
432
|
+
|
|
433
|
+
const trackTimeSpent = useCallback((ms: number) => {
|
|
434
|
+
setMetrics(prev => {
|
|
435
|
+
const updated = { ...prev, timeSpent: prev.timeSpent + ms };
|
|
436
|
+
saveEngagementMetrics(updated);
|
|
437
|
+
return updated;
|
|
438
|
+
});
|
|
439
|
+
}, []);
|
|
440
|
+
|
|
441
|
+
return { ...metrics, trackAction, trackTimeSpent };
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Step 2**: Use in `A2HSHint.tsx`:
|
|
446
|
+
```typescript
|
|
447
|
+
import { useEngagement } from '../hooks/useEngagement';
|
|
448
|
+
|
|
449
|
+
export function A2HSHint({ ... }: A2HSHintProps) {
|
|
450
|
+
const { actions, timeSpent } = useEngagement();
|
|
451
|
+
|
|
452
|
+
// Only show if user is engaged
|
|
453
|
+
const isEngaged = actions >= 5 && timeSpent >= 30000; // 30 seconds
|
|
454
|
+
const shouldShow = isEngaged && (forceShow || (!isInstalled && ...));
|
|
455
|
+
|
|
456
|
+
// ... rest of component
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Recommendation**: Option A (remove) unless there's concrete plan to use
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
### 5. Simplify Context Composition (P2-2)
|
|
465
|
+
|
|
466
|
+
**Issue**: `PwaProvider` conditionally wraps children with `PushProvider`
|
|
467
|
+
|
|
468
|
+
**File**: `context/InstallContext.tsx:81-84`
|
|
469
|
+
|
|
470
|
+
**Current Code**:
|
|
471
|
+
```typescript
|
|
472
|
+
// Wrap with PushProvider if configured
|
|
473
|
+
if (config.pushNotifications) {
|
|
474
|
+
content = <PushProvider {...config.pushNotifications}>{content}</PushProvider>;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return content;
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**Problem**: Magic wrapping, hard to understand
|
|
481
|
+
|
|
482
|
+
**✅ Fixed Code**:
|
|
483
|
+
|
|
484
|
+
**Step 1**: Create `ConditionalPushProvider` helper:
|
|
485
|
+
```typescript
|
|
486
|
+
// At top of InstallContext.tsx
|
|
487
|
+
function ConditionalPushProvider({
|
|
488
|
+
enabled,
|
|
489
|
+
children,
|
|
490
|
+
...config
|
|
491
|
+
}: PushNotificationOptions & { enabled: boolean; children: React.ReactNode }) {
|
|
492
|
+
if (!enabled) return <>{children}</>;
|
|
493
|
+
return <PushProvider {...config}>{children}</PushProvider>;
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Step 2**: Refactor `PwaProvider`:
|
|
498
|
+
```typescript
|
|
499
|
+
export function PwaProvider({ children, ...config }: PwaConfig & { children: React.ReactNode }) {
|
|
500
|
+
// If not enabled, acts as a simple pass-through
|
|
501
|
+
if (config.enabled === false) {
|
|
502
|
+
return <>{children}</>;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const prompt = useInstallPrompt();
|
|
506
|
+
|
|
507
|
+
const value: PwaContextValue = {
|
|
508
|
+
isIOS: prompt.isIOS,
|
|
509
|
+
isAndroid: prompt.isAndroid,
|
|
510
|
+
isSafari: prompt.isSafari,
|
|
511
|
+
isChrome: prompt.isChrome,
|
|
512
|
+
isInstalled: prompt.isInstalled,
|
|
513
|
+
canPrompt: prompt.canPrompt,
|
|
514
|
+
install: prompt.promptInstall,
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const showHint = config.showInstallHint !== false;
|
|
518
|
+
|
|
519
|
+
// ✅ Explicit composition tree
|
|
520
|
+
return (
|
|
521
|
+
<PwaContext.Provider value={value}>
|
|
522
|
+
<ConditionalPushProvider
|
|
523
|
+
enabled={!!config.pushNotifications}
|
|
524
|
+
vapidPublicKey={config.pushNotifications?.vapidPublicKey || ''}
|
|
525
|
+
subscribeEndpoint={config.pushNotifications?.subscribeEndpoint}
|
|
526
|
+
>
|
|
527
|
+
{children}
|
|
528
|
+
{showHint && (
|
|
529
|
+
<A2HSHint
|
|
530
|
+
resetAfterDays={config.resetAfterDays}
|
|
531
|
+
delayMs={config.delayMs}
|
|
532
|
+
logo={config.logo}
|
|
533
|
+
pushNotifications={config.pushNotifications}
|
|
534
|
+
/>
|
|
535
|
+
)}
|
|
536
|
+
</ConditionalPushProvider>
|
|
537
|
+
</PwaContext.Provider>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Benefits**:
|
|
543
|
+
- ✅ Explicit composition (no magic)
|
|
544
|
+
- ✅ Easy to understand
|
|
545
|
+
- ✅ Easy to test in isolation
|
|
546
|
+
- ✅ Same functionality
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
### 6. Add Server-Side Persistence (P2-3)
|
|
551
|
+
|
|
552
|
+
**Issue**: Push subscriptions not persisted on server
|
|
553
|
+
|
|
554
|
+
**Files**: Backend API routes + `hooks/usePushNotifications.ts`
|
|
555
|
+
|
|
556
|
+
#### Step 1: Create Backend API Routes
|
|
557
|
+
|
|
558
|
+
**Create `app/api/push/subscribe/route.ts`**:
|
|
559
|
+
```typescript
|
|
560
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
561
|
+
import { db } from '@/lib/db'; // Your database client
|
|
562
|
+
|
|
563
|
+
export async function POST(request: NextRequest) {
|
|
564
|
+
try {
|
|
565
|
+
const subscription: PushSubscription = await request.json();
|
|
566
|
+
|
|
567
|
+
// Validate subscription
|
|
568
|
+
if (!subscription.endpoint || !subscription.keys) {
|
|
569
|
+
return NextResponse.json(
|
|
570
|
+
{ success: false, error: 'Invalid subscription' },
|
|
571
|
+
{ status: 400 }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Get user ID (from auth session)
|
|
576
|
+
const userId = await getUserIdFromSession(request);
|
|
577
|
+
if (!userId) {
|
|
578
|
+
return NextResponse.json(
|
|
579
|
+
{ success: false, error: 'Unauthorized' },
|
|
580
|
+
{ status: 401 }
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Save to database
|
|
585
|
+
const subscriptionId = await db.pushSubscriptions.upsert({
|
|
586
|
+
where: {
|
|
587
|
+
endpoint: subscription.endpoint,
|
|
588
|
+
},
|
|
589
|
+
update: {
|
|
590
|
+
keys: subscription.keys,
|
|
591
|
+
updatedAt: new Date(),
|
|
592
|
+
},
|
|
593
|
+
create: {
|
|
594
|
+
userId,
|
|
595
|
+
endpoint: subscription.endpoint,
|
|
596
|
+
keys: subscription.keys,
|
|
597
|
+
expirationTime: subscription.expirationTime,
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
return NextResponse.json({
|
|
602
|
+
success: true,
|
|
603
|
+
subscriptionId,
|
|
604
|
+
});
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error('[API] Push subscription failed:', error);
|
|
607
|
+
return NextResponse.json(
|
|
608
|
+
{ success: false, error: 'Internal server error' },
|
|
609
|
+
{ status: 500 }
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Create `app/api/push/unsubscribe/route.ts`**:
|
|
616
|
+
```typescript
|
|
617
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
618
|
+
import { db } from '@/lib/db';
|
|
619
|
+
|
|
620
|
+
export async function DELETE(request: NextRequest) {
|
|
621
|
+
try {
|
|
622
|
+
const { endpoint } = await request.json();
|
|
623
|
+
|
|
624
|
+
const userId = await getUserIdFromSession(request);
|
|
625
|
+
if (!userId) {
|
|
626
|
+
return NextResponse.json(
|
|
627
|
+
{ success: false, error: 'Unauthorized' },
|
|
628
|
+
{ status: 401 }
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await db.pushSubscriptions.delete({
|
|
633
|
+
where: {
|
|
634
|
+
endpoint,
|
|
635
|
+
userId, // Ensure user can only delete their own
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
return NextResponse.json({ success: true });
|
|
640
|
+
} catch (error) {
|
|
641
|
+
console.error('[API] Push unsubscription failed:', error);
|
|
642
|
+
return NextResponse.json(
|
|
643
|
+
{ success: false, error: 'Internal server error' },
|
|
644
|
+
{ status: 500 }
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
**Database schema** (Prisma example):
|
|
651
|
+
```prisma
|
|
652
|
+
model PushSubscription {
|
|
653
|
+
id String @id @default(cuid())
|
|
654
|
+
userId String
|
|
655
|
+
endpoint String @unique
|
|
656
|
+
keys Json
|
|
657
|
+
expirationTime BigInt?
|
|
658
|
+
createdAt DateTime @default(now())
|
|
659
|
+
updatedAt DateTime @updatedAt
|
|
660
|
+
|
|
661
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
662
|
+
|
|
663
|
+
@@index([userId])
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
#### Step 2: Update Frontend Hook
|
|
668
|
+
|
|
669
|
+
**Update `hooks/usePushNotifications.ts`** (lines 117-123):
|
|
670
|
+
|
|
671
|
+
**Replace**:
|
|
672
|
+
```typescript
|
|
673
|
+
// Send subscription to server
|
|
674
|
+
if (options.subscribeEndpoint) {
|
|
675
|
+
await fetch(options.subscribeEndpoint, {
|
|
676
|
+
method: 'POST',
|
|
677
|
+
headers: { 'Content-Type': 'application/json' },
|
|
678
|
+
body: JSON.stringify(subscription),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
**With**:
|
|
684
|
+
```typescript
|
|
685
|
+
// Send subscription to server
|
|
686
|
+
if (options.subscribeEndpoint) {
|
|
687
|
+
const response = await fetch(options.subscribeEndpoint, {
|
|
688
|
+
method: 'POST',
|
|
689
|
+
headers: { 'Content-Type': 'application/json' },
|
|
690
|
+
body: JSON.stringify(subscription),
|
|
691
|
+
credentials: 'include', // Include cookies for auth
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
if (!response.ok) {
|
|
695
|
+
const errorData = await response.json().catch(() => ({}));
|
|
696
|
+
throw new Error(
|
|
697
|
+
`Server failed to save subscription: ${errorData.error || response.statusText}`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const { success, subscriptionId } = await response.json();
|
|
702
|
+
if (!success) {
|
|
703
|
+
throw new Error('Server reported failure to save subscription');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
pwaLogger.info('[usePushNotifications] Server saved subscription:', subscriptionId);
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
**Update unsubscribe** (lines 149-168):
|
|
711
|
+
|
|
712
|
+
**Replace**:
|
|
713
|
+
```typescript
|
|
714
|
+
const unsubscribe = async (): Promise<boolean> => {
|
|
715
|
+
if (!state.subscription) {
|
|
716
|
+
pwaLogger.warn('[usePushNotifications] No active subscription to unsubscribe');
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
await state.subscription.unsubscribe();
|
|
722
|
+
setState((prev) => ({
|
|
723
|
+
...prev,
|
|
724
|
+
isSubscribed: false,
|
|
725
|
+
subscription: null,
|
|
726
|
+
}));
|
|
727
|
+
pwaLogger.info('[usePushNotifications] Successfully unsubscribed from push notifications');
|
|
728
|
+
return true;
|
|
729
|
+
} catch (error) {
|
|
730
|
+
pwaLogger.error('[usePushNotifications] Unsubscribe failed:', error);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
**With**:
|
|
737
|
+
```typescript
|
|
738
|
+
const unsubscribe = async (): Promise<boolean> => {
|
|
739
|
+
if (!state.subscription) {
|
|
740
|
+
pwaLogger.warn('[usePushNotifications] No active subscription to unsubscribe');
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
// Unsubscribe client-side
|
|
746
|
+
await state.subscription.unsubscribe();
|
|
747
|
+
|
|
748
|
+
// Notify server
|
|
749
|
+
if (options?.subscribeEndpoint) {
|
|
750
|
+
const unsubscribeEndpoint = options.subscribeEndpoint.replace('/subscribe', '/unsubscribe');
|
|
751
|
+
await fetch(unsubscribeEndpoint, {
|
|
752
|
+
method: 'DELETE',
|
|
753
|
+
headers: { 'Content-Type': 'application/json' },
|
|
754
|
+
body: JSON.stringify({ endpoint: state.subscription.endpoint }),
|
|
755
|
+
credentials: 'include',
|
|
756
|
+
}).catch((err) => {
|
|
757
|
+
pwaLogger.warn('[usePushNotifications] Failed to notify server:', err);
|
|
758
|
+
// Don't fail if server notification fails
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
setState((prev) => ({
|
|
763
|
+
...prev,
|
|
764
|
+
isSubscribed: false,
|
|
765
|
+
subscription: null,
|
|
766
|
+
}));
|
|
767
|
+
pwaLogger.info('[usePushNotifications] Successfully unsubscribed from push notifications');
|
|
768
|
+
return true;
|
|
769
|
+
} catch (error) {
|
|
770
|
+
pwaLogger.error('[usePushNotifications] Unsubscribe failed:', error);
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
### 7. Improve Error Recovery (P2-4)
|
|
779
|
+
|
|
780
|
+
**Issue**: No retry logic for push subscription failures
|
|
781
|
+
|
|
782
|
+
**File**: `hooks/usePushNotifications.ts`
|
|
783
|
+
|
|
784
|
+
**Step 1**: Add retry helper:
|
|
785
|
+
```typescript
|
|
786
|
+
/**
|
|
787
|
+
* Retry a function with exponential backoff
|
|
788
|
+
*/
|
|
789
|
+
async function retryWithBackoff<T>(
|
|
790
|
+
fn: () => Promise<T>,
|
|
791
|
+
maxRetries: number = 3,
|
|
792
|
+
baseDelayMs: number = 1000
|
|
793
|
+
): Promise<T> {
|
|
794
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
795
|
+
try {
|
|
796
|
+
return await fn();
|
|
797
|
+
} catch (error) {
|
|
798
|
+
const isLastAttempt = attempt === maxRetries - 1;
|
|
799
|
+
if (isLastAttempt) {
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Exponential backoff: 1s, 2s, 4s, ...
|
|
804
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt);
|
|
805
|
+
pwaLogger.debug(`[usePushNotifications] Retry attempt ${attempt + 1}/${maxRetries} after ${delayMs}ms`);
|
|
806
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
throw new Error('Retry failed'); // Shouldn't reach here
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
**Step 2**: Update subscribe function signature:
|
|
814
|
+
```typescript
|
|
815
|
+
// Add to PushNotificationOptions type
|
|
816
|
+
export interface PushNotificationOptions {
|
|
817
|
+
vapidPublicKey: string;
|
|
818
|
+
subscribeEndpoint?: string;
|
|
819
|
+
// ✅ NEW options
|
|
820
|
+
onError?: (error: string) => void;
|
|
821
|
+
maxRetries?: number;
|
|
822
|
+
}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**Step 3**: Refactor subscribe with retry:
|
|
826
|
+
```typescript
|
|
827
|
+
const subscribe = async (): Promise<boolean> => {
|
|
828
|
+
if (!state.isSupported) {
|
|
829
|
+
pwaLogger.warn('[usePushNotifications] Push notifications not supported');
|
|
830
|
+
options?.onError?.('Push notifications not supported in this browser');
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (!options?.vapidPublicKey) {
|
|
835
|
+
const error = 'VAPID public key is required for push notifications';
|
|
836
|
+
pwaLogger.error('[usePushNotifications]', error);
|
|
837
|
+
options?.onError?.(error);
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
// Request permission
|
|
843
|
+
const permission = await Notification.requestPermission();
|
|
844
|
+
setState((prev) => ({ ...prev, permission }));
|
|
845
|
+
|
|
846
|
+
if (permission !== 'granted') {
|
|
847
|
+
const error = 'Notification permission denied';
|
|
848
|
+
pwaLogger.warn('[usePushNotifications]', error);
|
|
849
|
+
options?.onError?.(error);
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Get service worker
|
|
854
|
+
const registration = await navigator.serviceWorker.ready;
|
|
855
|
+
|
|
856
|
+
// Convert VAPID key
|
|
857
|
+
let applicationServerKey: Uint8Array;
|
|
858
|
+
try {
|
|
859
|
+
applicationServerKey = urlBase64ToUint8Array(options.vapidPublicKey);
|
|
860
|
+
pwaLogger.info('[usePushNotifications] VAPID key validated successfully');
|
|
861
|
+
} catch (e) {
|
|
862
|
+
const errorMsg = e instanceof VapidKeyError
|
|
863
|
+
? `Invalid VAPID key: ${e.message}`
|
|
864
|
+
: 'Failed to convert VAPID key';
|
|
865
|
+
pwaLogger.error('[usePushNotifications]', errorMsg);
|
|
866
|
+
options?.onError?.(errorMsg);
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Unsubscribe from existing subscription
|
|
871
|
+
const existingSub = await registration.pushManager.getSubscription();
|
|
872
|
+
if (existingSub) {
|
|
873
|
+
await existingSub.unsubscribe();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ✅ Subscribe with retry
|
|
877
|
+
const subscription = await retryWithBackoff(
|
|
878
|
+
async () => {
|
|
879
|
+
return await registration.pushManager.subscribe({
|
|
880
|
+
userVisibleOnly: true,
|
|
881
|
+
applicationServerKey: applicationServerKey as unknown as BufferSource,
|
|
882
|
+
});
|
|
883
|
+
},
|
|
884
|
+
options.maxRetries ?? 3
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// ✅ Send to server with retry
|
|
888
|
+
if (options.subscribeEndpoint) {
|
|
889
|
+
await retryWithBackoff(
|
|
890
|
+
async () => {
|
|
891
|
+
const response = await fetch(options.subscribeEndpoint!, {
|
|
892
|
+
method: 'POST',
|
|
893
|
+
headers: { 'Content-Type': 'application/json' },
|
|
894
|
+
body: JSON.stringify(subscription),
|
|
895
|
+
credentials: 'include',
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (!response.ok) {
|
|
899
|
+
const errorData = await response.json().catch(() => ({}));
|
|
900
|
+
throw new Error(errorData.error || response.statusText);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return response.json();
|
|
904
|
+
},
|
|
905
|
+
options.maxRetries ?? 3
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
setState((prev) => ({
|
|
910
|
+
...prev,
|
|
911
|
+
isSubscribed: true,
|
|
912
|
+
subscription,
|
|
913
|
+
}));
|
|
914
|
+
|
|
915
|
+
pwaLogger.success('[usePushNotifications] Successfully subscribed to push notifications');
|
|
916
|
+
return true;
|
|
917
|
+
|
|
918
|
+
} catch (error: any) {
|
|
919
|
+
const userError = getUserFriendlyError(error);
|
|
920
|
+
pwaLogger.error('[usePushNotifications] Subscribe failed:', error);
|
|
921
|
+
options?.onError?.(userError);
|
|
922
|
+
|
|
923
|
+
// Specific diagnostics for common errors
|
|
924
|
+
if (error.name === 'AbortError' || error.message?.includes('push service error')) {
|
|
925
|
+
pwaLogger.error('[usePushNotifications] Push service blocked. Possible causes:');
|
|
926
|
+
pwaLogger.error(' 1. Network: Firewall/VPN blocking ports 5228-5230');
|
|
927
|
+
pwaLogger.error(' 2. Browser: Privacy settings or Do Not Disturb mode');
|
|
928
|
+
pwaLogger.error(' 3. Browser: Shield/blocking settings (Brave, Firefox)');
|
|
929
|
+
pwaLogger.error(' 4. Browser: Corrupt profile (try Incognito)');
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Convert technical error to user-friendly message
|
|
938
|
+
*/
|
|
939
|
+
function getUserFriendlyError(error: any): string {
|
|
940
|
+
if (error.name === 'AbortError') {
|
|
941
|
+
return 'Push notifications blocked. Check your browser settings or try disabling VPN.';
|
|
942
|
+
}
|
|
943
|
+
if (error.message?.includes('push service error')) {
|
|
944
|
+
return 'Cannot connect to push service. Check your network connection.';
|
|
945
|
+
}
|
|
946
|
+
if (error.message?.includes('Server failed')) {
|
|
947
|
+
return 'Failed to save subscription. Please try again.';
|
|
948
|
+
}
|
|
949
|
+
return 'Failed to enable push notifications. Please try again later.';
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
**Step 4**: Use error callback in components:
|
|
954
|
+
```typescript
|
|
955
|
+
// PushPrompt.tsx
|
|
956
|
+
<PushPrompt
|
|
957
|
+
vapidPublicKey={vapidKey}
|
|
958
|
+
subscribeEndpoint="/api/push/subscribe"
|
|
959
|
+
maxRetries={2}
|
|
960
|
+
onError={(error) => {
|
|
961
|
+
toast.error('Push Notification Error', {
|
|
962
|
+
description: error,
|
|
963
|
+
});
|
|
964
|
+
}}
|
|
965
|
+
/>
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
---
|
|
969
|
+
|
|
970
|
+
## P3: Nice to Have
|
|
971
|
+
|
|
972
|
+
### 8. Add Testing (P3-1)
|
|
973
|
+
|
|
974
|
+
**Goal**: Add unit tests for utils, integration tests for hooks
|
|
975
|
+
|
|
976
|
+
**Setup**: Use Vitest + Testing Library
|
|
977
|
+
|
|
978
|
+
#### Install dependencies:
|
|
979
|
+
```bash
|
|
980
|
+
npm install -D vitest @testing-library/react @testing-library/react-hooks @testing-library/jest-dom
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
#### Example: Test `utils/platform.ts`
|
|
984
|
+
|
|
985
|
+
**Create `utils/__tests__/platform.test.ts`**:
|
|
986
|
+
```typescript
|
|
987
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
988
|
+
import { isStandalone, isMobileDevice, hasValidManifest, isStandaloneReliable } from '../platform';
|
|
989
|
+
|
|
990
|
+
describe('platform utilities', () => {
|
|
991
|
+
describe('isStandalone', () => {
|
|
992
|
+
it('returns false when window is undefined', () => {
|
|
993
|
+
// Simulates SSR
|
|
994
|
+
expect(isStandalone()).toBe(false);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it('checks matchMedia for standalone display mode', () => {
|
|
998
|
+
// Mock matchMedia
|
|
999
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
1000
|
+
writable: true,
|
|
1001
|
+
value: (query: string) => ({
|
|
1002
|
+
matches: query === '(display-mode: standalone)',
|
|
1003
|
+
media: query,
|
|
1004
|
+
addEventListener: () => {},
|
|
1005
|
+
removeEventListener: () => {},
|
|
1006
|
+
}),
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
expect(isStandalone()).toBe(true);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('falls back to navigator.standalone on iOS', () => {
|
|
1013
|
+
// Mock iOS environment
|
|
1014
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
1015
|
+
writable: true,
|
|
1016
|
+
value: undefined,
|
|
1017
|
+
});
|
|
1018
|
+
Object.defineProperty(navigator, 'standalone', {
|
|
1019
|
+
writable: true,
|
|
1020
|
+
value: true,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
expect(isStandalone()).toBe(true);
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe('isMobileDevice', () => {
|
|
1028
|
+
it('detects iOS devices', () => {
|
|
1029
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
1030
|
+
writable: true,
|
|
1031
|
+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
expect(isMobileDevice()).toBe(true);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it('detects Android devices', () => {
|
|
1038
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
1039
|
+
writable: true,
|
|
1040
|
+
value: 'Mozilla/5.0 (Linux; Android 10)',
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
expect(isMobileDevice()).toBe(true);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('returns false for desktop', () => {
|
|
1047
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
1048
|
+
writable: true,
|
|
1049
|
+
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
expect(isMobileDevice()).toBe(false);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
describe('hasValidManifest', () => {
|
|
1057
|
+
beforeEach(() => {
|
|
1058
|
+
document.head.innerHTML = '';
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('returns true when manifest link exists', () => {
|
|
1062
|
+
const link = document.createElement('link');
|
|
1063
|
+
link.rel = 'manifest';
|
|
1064
|
+
link.href = '/manifest.json';
|
|
1065
|
+
document.head.appendChild(link);
|
|
1066
|
+
|
|
1067
|
+
expect(hasValidManifest()).toBe(true);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('returns false when no manifest link', () => {
|
|
1071
|
+
expect(hasValidManifest()).toBe(false);
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
describe('isStandaloneReliable', () => {
|
|
1076
|
+
it('returns true for mobile PWA', () => {
|
|
1077
|
+
// Mock mobile + standalone
|
|
1078
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
1079
|
+
writable: true,
|
|
1080
|
+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
|
|
1081
|
+
});
|
|
1082
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
1083
|
+
writable: true,
|
|
1084
|
+
value: (query: string) => ({
|
|
1085
|
+
matches: query === '(display-mode: standalone)',
|
|
1086
|
+
}),
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
expect(isStandaloneReliable()).toBe(true);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it('requires manifest for desktop PWA', () => {
|
|
1093
|
+
// Mock desktop + standalone but no manifest
|
|
1094
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
1095
|
+
writable: true,
|
|
1096
|
+
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
|
1097
|
+
});
|
|
1098
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
1099
|
+
writable: true,
|
|
1100
|
+
value: (query: string) => ({
|
|
1101
|
+
matches: query === '(display-mode: standalone)',
|
|
1102
|
+
}),
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
expect(isStandaloneReliable()).toBe(false);
|
|
1106
|
+
|
|
1107
|
+
// Add manifest
|
|
1108
|
+
const link = document.createElement('link');
|
|
1109
|
+
link.rel = 'manifest';
|
|
1110
|
+
document.head.appendChild(link);
|
|
1111
|
+
|
|
1112
|
+
expect(isStandaloneReliable()).toBe(true);
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
**Run tests**:
|
|
1119
|
+
```bash
|
|
1120
|
+
npm run test
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
---
|
|
1124
|
+
|
|
1125
|
+
### 9. Improve Accessibility (P3-2)
|
|
1126
|
+
|
|
1127
|
+
**Goal**: Add focus traps, keyboard navigation, ARIA attributes
|
|
1128
|
+
|
|
1129
|
+
#### Example: Add focus trap to IOSGuideModal
|
|
1130
|
+
|
|
1131
|
+
**Install dependency**:
|
|
1132
|
+
```bash
|
|
1133
|
+
npm install focus-trap-react
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
**Update `components/IOSGuideModal.tsx`**:
|
|
1137
|
+
```typescript
|
|
1138
|
+
import FocusTrap from 'focus-trap-react';
|
|
1139
|
+
|
|
1140
|
+
export function IOSGuideModal({ onDismiss, open = true }: IOSGuideModalProps) {
|
|
1141
|
+
// Handle Escape key
|
|
1142
|
+
useEffect(() => {
|
|
1143
|
+
if (!open) return;
|
|
1144
|
+
|
|
1145
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1146
|
+
if (e.key === 'Escape') {
|
|
1147
|
+
onDismiss();
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
1152
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
1153
|
+
}, [open, onDismiss]);
|
|
1154
|
+
|
|
1155
|
+
return (
|
|
1156
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
|
1157
|
+
<DialogContent
|
|
1158
|
+
className="sm:max-w-md"
|
|
1159
|
+
role="alertdialog"
|
|
1160
|
+
aria-labelledby="ios-guide-title"
|
|
1161
|
+
aria-describedby="ios-guide-description"
|
|
1162
|
+
>
|
|
1163
|
+
<FocusTrap active={open}>
|
|
1164
|
+
<div>
|
|
1165
|
+
<DialogHeader className="text-left">
|
|
1166
|
+
<DialogTitle id="ios-guide-title" className="flex items-center gap-2">
|
|
1167
|
+
<Share className="w-5 h-5 text-primary" aria-hidden="true" />
|
|
1168
|
+
Add to Home Screen
|
|
1169
|
+
</DialogTitle>
|
|
1170
|
+
<DialogDescription id="ios-guide-description" className="text-left">
|
|
1171
|
+
Install this app on your iPhone for quick access and a better experience
|
|
1172
|
+
</DialogDescription>
|
|
1173
|
+
</DialogHeader>
|
|
1174
|
+
|
|
1175
|
+
<div className="space-y-3 py-4" role="list">
|
|
1176
|
+
{steps.map((step) => (
|
|
1177
|
+
<StepCard key={step.number} step={step} />
|
|
1178
|
+
))}
|
|
1179
|
+
</div>
|
|
1180
|
+
|
|
1181
|
+
<DialogFooter>
|
|
1182
|
+
<Button
|
|
1183
|
+
onClick={onDismiss}
|
|
1184
|
+
variant="default"
|
|
1185
|
+
className="w-full"
|
|
1186
|
+
aria-label="Close installation guide"
|
|
1187
|
+
>
|
|
1188
|
+
<Check className="w-4 h-4 mr-2" aria-hidden="true" />
|
|
1189
|
+
Got It
|
|
1190
|
+
</Button>
|
|
1191
|
+
</DialogFooter>
|
|
1192
|
+
</div>
|
|
1193
|
+
</FocusTrap>
|
|
1194
|
+
</DialogContent>
|
|
1195
|
+
</Dialog>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
**Add to StepCard**:
|
|
1201
|
+
```typescript
|
|
1202
|
+
function StepCard({ step }: { step: InstallStep }) {
|
|
1203
|
+
return (
|
|
1204
|
+
<Card className="border border-border" role="listitem">
|
|
1205
|
+
<CardContent className="p-4">
|
|
1206
|
+
<div className="flex items-start gap-3">
|
|
1207
|
+
<div
|
|
1208
|
+
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
|
|
1209
|
+
style={{ width: '32px', height: '32px' }}
|
|
1210
|
+
aria-label={`Step ${step.number}`}
|
|
1211
|
+
>
|
|
1212
|
+
<span className="text-sm font-semibold" aria-hidden="true">
|
|
1213
|
+
{step.number}
|
|
1214
|
+
</span>
|
|
1215
|
+
</div>
|
|
1216
|
+
|
|
1217
|
+
<div className="flex-1 min-w-0">
|
|
1218
|
+
<div className="flex items-center gap-2 mb-1">
|
|
1219
|
+
<step.icon className="w-5 h-5 text-primary" aria-hidden="true" />
|
|
1220
|
+
<h3 className="font-semibold text-foreground">{step.title}</h3>
|
|
1221
|
+
</div>
|
|
1222
|
+
<p className="text-sm text-muted-foreground">{step.description}</p>
|
|
1223
|
+
</div>
|
|
1224
|
+
</div>
|
|
1225
|
+
</CardContent>
|
|
1226
|
+
</Card>
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
---
|
|
1232
|
+
|
|
1233
|
+
### 10. Enable TypeScript Strict Mode (P3-3)
|
|
1234
|
+
|
|
1235
|
+
**Goal**: Enable strict type checking to catch more errors
|
|
1236
|
+
|
|
1237
|
+
#### Step 1: Enable in tsconfig.json
|
|
1238
|
+
|
|
1239
|
+
```json
|
|
1240
|
+
{
|
|
1241
|
+
"compilerOptions": {
|
|
1242
|
+
"strict": true,
|
|
1243
|
+
"noImplicitAny": true,
|
|
1244
|
+
"strictNullChecks": true,
|
|
1245
|
+
"strictFunctionTypes": true,
|
|
1246
|
+
"strictBindCallApply": true,
|
|
1247
|
+
"strictPropertyInitialization": true,
|
|
1248
|
+
"noImplicitThis": true,
|
|
1249
|
+
"alwaysStrict": true
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
#### Step 2: Fix type errors
|
|
1255
|
+
|
|
1256
|
+
**Example fixes**:
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
// ❌ Before
|
|
1260
|
+
const handleMessage = (event) => {
|
|
1261
|
+
if (event.data && event.data.type === 'PUSH_RECEIVED') {
|
|
1262
|
+
// ...
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
// ✅ After
|
|
1267
|
+
const handleMessage = (event: MessageEvent<{
|
|
1268
|
+
type: string;
|
|
1269
|
+
notification?: Partial<PushMessage>
|
|
1270
|
+
}>) => {
|
|
1271
|
+
if (event.data?.type === 'PUSH_RECEIVED') {
|
|
1272
|
+
// ...
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
```typescript
|
|
1278
|
+
// ❌ Before
|
|
1279
|
+
const subscription: any = await registration.pushManager.subscribe(options);
|
|
1280
|
+
|
|
1281
|
+
// ✅ After
|
|
1282
|
+
const subscription: PushSubscription = await registration.pushManager.subscribe(options);
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
#### Step 3: Add strict null checks
|
|
1286
|
+
|
|
1287
|
+
```typescript
|
|
1288
|
+
// ❌ Before
|
|
1289
|
+
const cleanup = onDisplayModeChange((isStandalone) => {
|
|
1290
|
+
setState(prev => ({ ...prev, isInstalled: isStandalone }));
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
// ✅ After (explicit null check)
|
|
1294
|
+
const cleanup = onDisplayModeChange((isStandalone) => {
|
|
1295
|
+
setState((prev) => ({ ...prev, isInstalled: isStandalone }));
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
return cleanup; // TypeScript ensures cleanup is always returned
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
---
|
|
1302
|
+
|
|
1303
|
+
## Summary
|
|
1304
|
+
|
|
1305
|
+
**P1 Fixes** (30 min):
|
|
1306
|
+
- ✅ Remove VAPID_PRIVATE_KEY from config.ts
|
|
1307
|
+
- ✅ Replace 3 console.error with pwaLogger
|
|
1308
|
+
- ✅ Centralize localStorage keys
|
|
1309
|
+
|
|
1310
|
+
**P2 Fixes** (4 hours):
|
|
1311
|
+
- ✅ Remove or use EngagementMetrics
|
|
1312
|
+
- ✅ Simplify context composition
|
|
1313
|
+
- ✅ Add server-side persistence
|
|
1314
|
+
- ✅ Improve error recovery with retry
|
|
1315
|
+
|
|
1316
|
+
**P3 Enhancements** (Ongoing):
|
|
1317
|
+
- ✅ Add testing (utils → hooks → components)
|
|
1318
|
+
- ✅ Improve accessibility (focus traps, ARIA)
|
|
1319
|
+
- ✅ Enable TypeScript strict mode
|
|
1320
|
+
|
|
1321
|
+
**Next Steps**: Start with P1, deploy, then tackle P2 incrementally.
|