@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.
Files changed (40) hide show
  1. package/package.json +5 -5
  2. package/src/layouts/AppLayout/BaseApp.tsx +31 -25
  3. package/src/layouts/shared/types.ts +36 -0
  4. package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
  5. package/src/snippets/PWA/@docs/research.md +576 -0
  6. package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
  7. package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
  8. package/src/snippets/PWA/@refactoring/README.md +204 -0
  9. package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
  10. package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
  11. package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
  12. package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
  13. package/src/snippets/PWA/@refactoring2/README.md +85 -0
  14. package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
  15. package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
  16. package/src/snippets/PWA/README.md +387 -0
  17. package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
  18. package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
  19. package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
  20. package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
  21. package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
  22. package/src/snippets/PWA/config.ts +20 -0
  23. package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
  24. package/src/snippets/PWA/context/InstallContext.tsx +118 -0
  25. package/src/snippets/PWA/context/PushContext.tsx +156 -0
  26. package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
  27. package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
  28. package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
  29. package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
  30. package/src/snippets/PWA/index.ts +95 -0
  31. package/src/snippets/PWA/types/components.ts +101 -0
  32. package/src/snippets/PWA/types/index.ts +26 -0
  33. package/src/snippets/PWA/types/install.ts +38 -0
  34. package/src/snippets/PWA/types/platform.ts +29 -0
  35. package/src/snippets/PWA/types/push.ts +21 -0
  36. package/src/snippets/PWA/utils/localStorage.ts +203 -0
  37. package/src/snippets/PWA/utils/logger.ts +149 -0
  38. package/src/snippets/PWA/utils/platform.ts +151 -0
  39. package/src/snippets/PWA/utils/vapid.ts +226 -0
  40. 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.