@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,1109 @@
1
+ # PWA Snippets: Конкретные предложения по рефакторингу
2
+
3
+ **Дата:** 2025-12-15
4
+ **Статус:** Предложения для обсуждения
5
+
6
+ ---
7
+
8
+ ## Содержание
9
+
10
+ 1. [Proposal #1: Унификация isStandalone()](#proposal-1-унификация-isstandalone)
11
+ 2. [Proposal #2: Улучшенный isPWA с кешированием](#proposal-2-улучшенный-ispwa-с-кешированием)
12
+ 3. [Proposal #3: Production-ready логирование](#proposal-3-production-ready-логирование)
13
+ 4. [Proposal #4: Валидация VAPID ключа](#proposal-4-валидация-vapid-ключа)
14
+ 5. [Proposal #5: Разделение ответственности хуков](#proposal-5-разделение-ответственности-хуков)
15
+ 6. [Proposal #6: Упрощение контекстов](#proposal-6-упрощение-контекстов)
16
+
17
+ ---
18
+
19
+ ## Proposal #1: Унификация isStandalone()
20
+
21
+ ### Проблема
22
+
23
+ Функция `isStandalone()` дублируется в двух местах:
24
+ - `hooks/useIsPWA.ts:14-24`
25
+ - `hooks/useInstallPrompt.ts:19-29`
26
+
27
+ ### Решение
28
+
29
+ Создать единую утилиту в `utils/platform.ts`
30
+
31
+ ### Код
32
+
33
+ ```typescript
34
+ // utils/platform.ts
35
+
36
+ /**
37
+ * Проверяет, запущено ли приложение в standalone режиме (PWA)
38
+ *
39
+ * Проверяет:
40
+ * 1. Modern: matchMedia display-mode (все современные браузеры)
41
+ * 2. Legacy: navigator.standalone (iOS Safari)
42
+ * 3. Fallback: для старых браузеров без matchMedia
43
+ *
44
+ * @returns true если приложение запущено как PWA
45
+ */
46
+ export function isStandalone(): boolean {
47
+ if (typeof window === 'undefined') return false;
48
+
49
+ // Fallback для старых браузеров без matchMedia
50
+ if (!window.matchMedia) {
51
+ // Только legacy iOS check
52
+ const nav = navigator as Navigator & { standalone?: boolean };
53
+ return nav.standalone === true;
54
+ }
55
+
56
+ // Modern approach: display-mode media query
57
+ const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
58
+
59
+ // Legacy iOS check
60
+ const nav = navigator as Navigator & { standalone?: boolean };
61
+ const isStandaloneNavigator = nav.standalone === true;
62
+
63
+ return isStandaloneDisplay || isStandaloneNavigator;
64
+ }
65
+
66
+ /**
67
+ * Проверяет, является ли устройство мобильным
68
+ */
69
+ export function isMobileDevice(): boolean {
70
+ if (typeof window === 'undefined') return false;
71
+ return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
72
+ }
73
+
74
+ /**
75
+ * Проверяет валидность манифеста
76
+ */
77
+ export function hasValidManifest(): boolean {
78
+ if (typeof document === 'undefined') return false;
79
+ const manifestLink = document.querySelector('link[rel="manifest"]');
80
+ return !!manifestLink;
81
+ }
82
+
83
+ /**
84
+ * Надежная проверка PWA режима с учетом edge cases
85
+ *
86
+ * Дополнительно проверяет:
87
+ * - Наличие манифеста для desktop браузеров
88
+ * - Является ли устройство мобильным
89
+ *
90
+ * @returns true если приложение запущено как настоящий PWA
91
+ */
92
+ export function isStandaloneReliable(): boolean {
93
+ const standalone = isStandalone();
94
+ if (!standalone) return false;
95
+
96
+ // Для мобильных устройств достаточно standalone
97
+ if (isMobileDevice()) return true;
98
+
99
+ // Для desktop дополнительно проверяем манифест
100
+ // (Safari на Mac может открывать в standalone через "Add to Dock")
101
+ return hasValidManifest();
102
+ }
103
+ ```
104
+
105
+ ### Миграция
106
+
107
+ ```typescript
108
+ // hooks/useIsPWA.ts
109
+ import { isStandalone } from '../utils/platform';
110
+
111
+ export function useIsPWA(): boolean {
112
+ const [isPWA, setIsPWA] = useState(isStandalone);
113
+ // ... rest of the code
114
+ }
115
+
116
+ // hooks/useInstallPrompt.ts
117
+ import { isStandalone } from '../utils/platform';
118
+
119
+ export function useInstallPrompt() {
120
+ const [state, setState] = useState<InstallPromptState>(() => {
121
+ // ...
122
+ return {
123
+ // ...
124
+ isInstalled: isStandalone(),
125
+ // ...
126
+ };
127
+ });
128
+ // ... rest of the code
129
+ }
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Proposal #2: Улучшенный isPWA с кешированием
135
+
136
+ ### Проблема
137
+
138
+ Текущая реализация `useIsPWA`:
139
+ - Пересчитывает при каждом рендере
140
+ - Нет кеширования между перезагрузками страницы
141
+ - Не обрабатывает edge cases
142
+
143
+ ### Решение
144
+
145
+ Добавить кеширование в sessionStorage и улучшить логику
146
+
147
+ ### Код
148
+
149
+ ```typescript
150
+ // hooks/useIsPWA.ts
151
+ 'use client';
152
+
153
+ import { useState, useEffect } from 'react';
154
+ import { isStandalone, isStandaloneReliable } from '../utils/platform';
155
+
156
+ const CACHE_KEY = 'pwa_is_standalone';
157
+
158
+ /**
159
+ * Hook для определения PWA режима
160
+ *
161
+ * Возвращает true если приложение запущено в standalone режиме (PWA).
162
+ * Результат кешируется в sessionStorage для быстрой инициализации.
163
+ *
164
+ * @param options.reliable - использовать надежную проверку (с проверкой манифеста для desktop)
165
+ * @returns true если приложение запущено как PWA
166
+ */
167
+ export function useIsPWA(options?: { reliable?: boolean }): boolean {
168
+ const checkFunction = options?.reliable ? isStandaloneReliable : isStandalone;
169
+
170
+ const [isPWA, setIsPWA] = useState<boolean>(() => {
171
+ // Попытка восстановить из кеша
172
+ if (typeof window !== 'undefined') {
173
+ try {
174
+ const cached = sessionStorage.getItem(CACHE_KEY);
175
+ if (cached !== null) {
176
+ return cached === 'true';
177
+ }
178
+ } catch {
179
+ // Игнорируем ошибки доступа к sessionStorage
180
+ }
181
+ }
182
+
183
+ // Первичная проверка
184
+ return checkFunction();
185
+ });
186
+
187
+ useEffect(() => {
188
+ // Проверка после монтирования (может отличаться от SSR)
189
+ const isStandaloneMode = checkFunction();
190
+ setIsPWA(isStandaloneMode);
191
+
192
+ // Сохранение в кеш
193
+ if (typeof window !== 'undefined') {
194
+ try {
195
+ sessionStorage.setItem(CACHE_KEY, String(isStandaloneMode));
196
+ } catch {
197
+ // Игнорируем ошибки доступа к sessionStorage
198
+ }
199
+ }
200
+
201
+ // Отслеживание изменений display-mode
202
+ if (typeof window === 'undefined' || !window.matchMedia) return;
203
+
204
+ const mediaQuery = window.matchMedia('(display-mode: standalone)');
205
+
206
+ const handleChange = (e: MediaQueryListEvent) => {
207
+ const newValue = e.matches;
208
+ setIsPWA(newValue);
209
+
210
+ // Обновление кеша
211
+ try {
212
+ sessionStorage.setItem(CACHE_KEY, String(newValue));
213
+ } catch {
214
+ // Игнорируем ошибки
215
+ }
216
+ };
217
+
218
+ // Современные браузеры
219
+ mediaQuery.addEventListener('change', handleChange);
220
+
221
+ return () => {
222
+ mediaQuery.removeEventListener('change', handleChange);
223
+ };
224
+ }, [checkFunction]);
225
+
226
+ return isPWA;
227
+ }
228
+
229
+ /**
230
+ * Сбросить кеш isPWA
231
+ * Полезно для тестирования
232
+ */
233
+ export function clearIsPWACache(): void {
234
+ if (typeof window === 'undefined') return;
235
+ try {
236
+ sessionStorage.removeItem(CACHE_KEY);
237
+ } catch {
238
+ // Игнорируем ошибки
239
+ }
240
+ }
241
+ ```
242
+
243
+ ### Использование
244
+
245
+ ```typescript
246
+ // Базовая проверка (текущее поведение)
247
+ const isPWA = useIsPWA();
248
+
249
+ // Надежная проверка (с проверкой манифеста)
250
+ const isPWA = useIsPWA({ reliable: true });
251
+
252
+ // Очистка кеша (для тестирования)
253
+ import { clearIsPWACache } from '@djangocfg/layouts/snippets';
254
+ clearIsPWACache();
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Proposal #3: Production-ready логирование
260
+
261
+ ### Проблема
262
+
263
+ `usePushNotifications.ts` содержит ~20 `consola` логов, которые загрязняют production консоль и могут раскрывать чувствительные данные.
264
+
265
+ ### Решение
266
+
267
+ Создать условный логгер, который работает только в development режиме
268
+
269
+ ### Код
270
+
271
+ ```typescript
272
+ // utils/logger.ts
273
+
274
+ import { consola } from 'consola';
275
+
276
+ const isDevelopment = process.env.NODE_ENV === 'development';
277
+ const isDebugEnabled = typeof window !== 'undefined' && localStorage.getItem('pwa_debug') === 'true';
278
+
279
+ /**
280
+ * PWA Logger с условным логированием
281
+ *
282
+ * В production логирует только ошибки
283
+ * В development логирует все уровни
284
+ *
285
+ * Для включения debug режима в production:
286
+ * localStorage.setItem('pwa_debug', 'true')
287
+ */
288
+ export const pwaLogger = {
289
+ /**
290
+ * Info level - только development или debug режим
291
+ */
292
+ info: (...args: any[]): void => {
293
+ if (isDevelopment || isDebugEnabled) {
294
+ consola.info(...args);
295
+ }
296
+ },
297
+
298
+ /**
299
+ * Warn level - только development или debug режим
300
+ */
301
+ warn: (...args: any[]): void => {
302
+ if (isDevelopment || isDebugEnabled) {
303
+ consola.warn(...args);
304
+ }
305
+ },
306
+
307
+ /**
308
+ * Error level - всегда логируется
309
+ */
310
+ error: (...args: any[]): void => {
311
+ consola.error(...args);
312
+ },
313
+
314
+ /**
315
+ * Debug level - только при явном включении
316
+ */
317
+ debug: (...args: any[]): void => {
318
+ if (isDebugEnabled) {
319
+ consola.debug(...args);
320
+ }
321
+ },
322
+ };
323
+
324
+ /**
325
+ * Включить debug режим
326
+ */
327
+ export function enablePWADebug(): void {
328
+ if (typeof window !== 'undefined') {
329
+ localStorage.setItem('pwa_debug', 'true');
330
+ consola.info('[PWA] Debug mode enabled. Reload page to see debug logs.');
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Выключить debug режим
336
+ */
337
+ export function disablePWADebug(): void {
338
+ if (typeof window !== 'undefined') {
339
+ localStorage.removeItem('pwa_debug');
340
+ consola.info('[PWA] Debug mode disabled.');
341
+ }
342
+ }
343
+ ```
344
+
345
+ ### Миграция
346
+
347
+ ```typescript
348
+ // hooks/usePushNotifications.ts
349
+
350
+ // До:
351
+ import { consola } from 'consola';
352
+ consola.info('[usePushNotifications] VAPID Key length:', options.vapidPublicKey?.length);
353
+
354
+ // После:
355
+ import { pwaLogger } from '../utils/logger';
356
+ pwaLogger.info('[usePushNotifications] VAPID Key length:', options.vapidPublicKey?.length);
357
+
358
+ // Ошибки всегда логируются:
359
+ pwaLogger.error('[usePushNotifications] Subscribe failed:', error);
360
+ ```
361
+
362
+ ### Debug в production
363
+
364
+ ```javascript
365
+ // В консоли браузера:
366
+ localStorage.setItem('pwa_debug', 'true');
367
+ location.reload();
368
+
369
+ // Или через helper:
370
+ import { enablePWADebug } from '@djangocfg/layouts/snippets';
371
+ enablePWADebug();
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Proposal #4: Валидация VAPID ключа
377
+
378
+ ### Проблема
379
+
380
+ Текущее преобразование VAPID ключа:
381
+ - Не валидирует формат входного ключа
382
+ - Не проверяет длину (должна быть 65 байт для P-256)
383
+ - Неинформативные ошибки
384
+
385
+ ### Решение
386
+
387
+ Добавить полную валидацию с понятными ошибками
388
+
389
+ ### Код
390
+
391
+ ```typescript
392
+ // utils/vapid.ts
393
+
394
+ /**
395
+ * Ошибки валидации VAPID ключа
396
+ */
397
+ export class VapidKeyError extends Error {
398
+ constructor(message: string, public readonly code: string) {
399
+ super(message);
400
+ this.name = 'VapidKeyError';
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Валидирует и преобразует VAPID public key из base64url в Uint8Array
406
+ *
407
+ * VAPID ключ должен быть:
408
+ * - Base64url encoded строкой
409
+ * - 65 байт после декодирования (P-256 uncompressed key)
410
+ * - Начинаться с 0x04 (uncompressed point indicator)
411
+ *
412
+ * @param base64String - VAPID public key в base64url формате
413
+ * @returns Uint8Array для использования в pushManager.subscribe()
414
+ * @throws VapidKeyError если ключ невалиден
415
+ */
416
+ export function urlBase64ToUint8Array(base64String: string): Uint8Array {
417
+ // 1. Валидация входа
418
+ if (!base64String) {
419
+ throw new VapidKeyError(
420
+ 'VAPID public key is required',
421
+ 'VAPID_EMPTY'
422
+ );
423
+ }
424
+
425
+ if (typeof base64String !== 'string') {
426
+ throw new VapidKeyError(
427
+ 'VAPID public key must be a string',
428
+ 'VAPID_INVALID_TYPE'
429
+ );
430
+ }
431
+
432
+ // 2. Преобразование base64url в base64
433
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
434
+ const base64 = (base64String + padding)
435
+ .replace(/-/g, '+')
436
+ .replace(/_/g, '/');
437
+
438
+ // 3. Декодирование base64
439
+ let rawData: string;
440
+ try {
441
+ rawData = window.atob(base64);
442
+ } catch (e) {
443
+ throw new VapidKeyError(
444
+ `Invalid base64url format: ${e instanceof Error ? e.message : String(e)}`,
445
+ 'VAPID_INVALID_BASE64'
446
+ );
447
+ }
448
+
449
+ // 4. Преобразование в Uint8Array
450
+ const outputArray = new Uint8Array(rawData.length);
451
+ for (let i = 0; i < rawData.length; i++) {
452
+ outputArray[i] = rawData.charCodeAt(i);
453
+ }
454
+
455
+ // 5. Валидация длины (P-256 uncompressed = 65 bytes)
456
+ if (outputArray.length !== 65) {
457
+ throw new VapidKeyError(
458
+ `Invalid key length: expected 65 bytes (P-256 uncompressed), got ${outputArray.length} bytes`,
459
+ 'VAPID_INVALID_LENGTH'
460
+ );
461
+ }
462
+
463
+ // 6. Валидация формата (должен начинаться с 0x04)
464
+ if (outputArray[0] !== 0x04) {
465
+ throw new VapidKeyError(
466
+ `Invalid key format: must start with 0x04 (uncompressed P-256 point), got 0x${outputArray[0].toString(16).padStart(2, '0')}`,
467
+ 'VAPID_INVALID_FORMAT'
468
+ );
469
+ }
470
+
471
+ return outputArray;
472
+ }
473
+
474
+ /**
475
+ * Валидирует VAPID ключ без преобразования
476
+ *
477
+ * @param base64String - VAPID public key
478
+ * @returns true если ключ валиден
479
+ */
480
+ export function isValidVapidKey(base64String: string): boolean {
481
+ try {
482
+ urlBase64ToUint8Array(base64String);
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Получает информацию о VAPID ключе для отладки
491
+ */
492
+ export function getVapidKeyInfo(base64String: string): {
493
+ valid: boolean;
494
+ length?: number;
495
+ firstByte?: string;
496
+ error?: string;
497
+ } {
498
+ try {
499
+ const key = urlBase64ToUint8Array(base64String);
500
+ return {
501
+ valid: true,
502
+ length: key.length,
503
+ firstByte: `0x${key[0].toString(16).padStart(2, '0')}`,
504
+ };
505
+ } catch (e) {
506
+ return {
507
+ valid: false,
508
+ error: e instanceof VapidKeyError ? e.message : String(e),
509
+ };
510
+ }
511
+ }
512
+ ```
513
+
514
+ ### Миграция
515
+
516
+ ```typescript
517
+ // hooks/usePushNotifications.ts
518
+
519
+ // До:
520
+ const urlBase64ToUint8Array = (base64String: string) => {
521
+ // ... inline implementation
522
+ };
523
+
524
+ // После:
525
+ import { urlBase64ToUint8Array, VapidKeyError, pwaLogger } from '../utils';
526
+
527
+ try {
528
+ applicationServerKey = urlBase64ToUint8Array(options.vapidPublicKey);
529
+ pwaLogger.info('[usePushNotifications] VAPID key validated successfully');
530
+ } catch (e) {
531
+ if (e instanceof VapidKeyError) {
532
+ pwaLogger.error(`[usePushNotifications] Invalid VAPID key: ${e.message} (code: ${e.code})`);
533
+ return false;
534
+ }
535
+ throw e;
536
+ }
537
+ ```
538
+
539
+ ### Использование для отладки
540
+
541
+ ```typescript
542
+ import { getVapidKeyInfo } from '@djangocfg/layouts/snippets';
543
+
544
+ const info = getVapidKeyInfo(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY);
545
+ console.log('VAPID Key Info:', info);
546
+ // {
547
+ // valid: true,
548
+ // length: 65,
549
+ // firstByte: '0x04'
550
+ // }
551
+ ```
552
+
553
+ ---
554
+
555
+ ## Proposal #5: Разделение ответственности хуков
556
+
557
+ ### Проблема
558
+
559
+ `useInstallPrompt` делает слишком много:
560
+ - Детекция платформы
561
+ - Управление состоянием установки
562
+ - Обработка событий
563
+ - Определение standalone режима
564
+
565
+ ### Решение
566
+
567
+ Разделить на отдельные специализированные хуки
568
+
569
+ ### Код
570
+
571
+ #### 5.1 usePlatform - Платформенная детекция
572
+
573
+ ```typescript
574
+ // hooks/usePlatform.ts
575
+ 'use client';
576
+
577
+ import { useState, useEffect } from 'react';
578
+ import { useBrowserDetect, useDeviceDetect } from '@djangocfg/ui-nextjs';
579
+
580
+ export interface PlatformInfo {
581
+ // OS
582
+ isIOS: boolean;
583
+ isAndroid: boolean;
584
+ isMobile: boolean;
585
+
586
+ // Browser
587
+ isSafari: boolean;
588
+ isChrome: boolean;
589
+ isChromium: boolean;
590
+
591
+ // Capabilities
592
+ supportsBeforeInstallPrompt: boolean;
593
+ supportsNotifications: boolean;
594
+ supportsPushManager: boolean;
595
+ }
596
+
597
+ /**
598
+ * Hook для детекции платформы и возможностей браузера
599
+ *
600
+ * Кеширует результат для производительности
601
+ */
602
+ export function usePlatform(): PlatformInfo {
603
+ const browser = useBrowserDetect();
604
+ const device = useDeviceDetect();
605
+
606
+ const [platform] = useState<PlatformInfo>(() => {
607
+ if (typeof window === 'undefined') {
608
+ return {
609
+ isIOS: false,
610
+ isAndroid: false,
611
+ isMobile: false,
612
+ isSafari: false,
613
+ isChrome: false,
614
+ isChromium: false,
615
+ supportsBeforeInstallPrompt: false,
616
+ supportsNotifications: false,
617
+ supportsPushManager: false,
618
+ };
619
+ }
620
+
621
+ // Real Safari = Safari && NOT Chromium
622
+ const isSafari = browser.isSafari && !browser.isChromium;
623
+
624
+ return {
625
+ isIOS: device.isIOS,
626
+ isAndroid: device.isAndroid,
627
+ isMobile: device.isMobile,
628
+ isSafari,
629
+ isChrome: browser.isChrome,
630
+ isChromium: browser.isChromium,
631
+ supportsBeforeInstallPrompt: 'onbeforeinstallprompt' in window,
632
+ supportsNotifications: 'Notification' in window,
633
+ supportsPushManager: 'serviceWorker' in navigator && 'PushManager' in window,
634
+ };
635
+ });
636
+
637
+ return platform;
638
+ }
639
+ ```
640
+
641
+ #### 5.2 useInstallState - Состояние установки
642
+
643
+ ```typescript
644
+ // hooks/useInstallState.ts
645
+ 'use client';
646
+
647
+ import { useState, useEffect } from 'react';
648
+ import { isStandalone } from '../utils/platform';
649
+ import type { BeforeInstallPromptEvent } from '../types';
650
+
651
+ export interface InstallState {
652
+ isInstalled: boolean;
653
+ canPrompt: boolean;
654
+ deferredPrompt: BeforeInstallPromptEvent | null;
655
+ }
656
+
657
+ /**
658
+ * Hook для управления состоянием установки PWA
659
+ */
660
+ export function useInstallState(): InstallState {
661
+ const [state, setState] = useState<InstallState>({
662
+ isInstalled: false,
663
+ canPrompt: false,
664
+ deferredPrompt: null,
665
+ });
666
+
667
+ useEffect(() => {
668
+ // Initial check
669
+ setState((prev) => ({
670
+ ...prev,
671
+ isInstalled: isStandalone(),
672
+ }));
673
+
674
+ if (typeof window === 'undefined') return;
675
+
676
+ // Listen for beforeinstallprompt (Android Chrome)
677
+ const handleBeforeInstallPrompt = (e: Event) => {
678
+ e.preventDefault();
679
+ const event = e as BeforeInstallPromptEvent;
680
+
681
+ setState((prev) => ({
682
+ ...prev,
683
+ canPrompt: true,
684
+ deferredPrompt: event,
685
+ }));
686
+ };
687
+
688
+ // Listen for appinstalled (Android Chrome)
689
+ const handleAppInstalled = () => {
690
+ setState((prev) => ({
691
+ ...prev,
692
+ canPrompt: false,
693
+ deferredPrompt: null,
694
+ isInstalled: true,
695
+ }));
696
+ };
697
+
698
+ // Listen for display-mode changes
699
+ const mediaQuery = window.matchMedia('(display-mode: standalone)');
700
+ const handleDisplayModeChange = (e: MediaQueryListEvent) => {
701
+ if (e.matches) {
702
+ setState((prev) => ({
703
+ ...prev,
704
+ isInstalled: true,
705
+ canPrompt: false,
706
+ deferredPrompt: null,
707
+ }));
708
+ }
709
+ };
710
+
711
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
712
+ window.addEventListener('appinstalled', handleAppInstalled);
713
+ mediaQuery.addEventListener('change', handleDisplayModeChange);
714
+
715
+ return () => {
716
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
717
+ window.removeEventListener('appinstalled', handleAppInstalled);
718
+ mediaQuery.removeEventListener('change', handleDisplayModeChange);
719
+ };
720
+ }, []);
721
+
722
+ return state;
723
+ }
724
+ ```
725
+
726
+ #### 5.3 useInstallActions - Действия установки
727
+
728
+ ```typescript
729
+ // hooks/useInstallActions.ts
730
+ 'use client';
731
+
732
+ import { useCallback } from 'react';
733
+ import { markAppInstalled } from '../utils/localStorage';
734
+ import { pwaLogger } from '../utils/logger';
735
+ import type { BeforeInstallPromptEvent, InstallOutcome } from '../types';
736
+
737
+ export interface InstallActions {
738
+ promptInstall: () => Promise<InstallOutcome>;
739
+ }
740
+
741
+ /**
742
+ * Hook для действий установки PWA
743
+ */
744
+ export function useInstallActions(deferredPrompt: BeforeInstallPromptEvent | null): InstallActions {
745
+ const promptInstall = useCallback(async (): Promise<InstallOutcome> => {
746
+ if (!deferredPrompt) {
747
+ pwaLogger.warn('[useInstallActions] No deferred prompt available');
748
+ return null;
749
+ }
750
+
751
+ try {
752
+ // Show native prompt
753
+ await deferredPrompt.prompt();
754
+
755
+ // Wait for user response
756
+ const { outcome } = await deferredPrompt.userChoice;
757
+
758
+ pwaLogger.info('[useInstallActions] Install outcome:', outcome);
759
+
760
+ // Mark as installed if accepted
761
+ if (outcome === 'accepted') {
762
+ markAppInstalled();
763
+ }
764
+
765
+ return outcome;
766
+ } catch (error) {
767
+ pwaLogger.error('[useInstallActions] Error showing install prompt:', error);
768
+ return null;
769
+ }
770
+ }, [deferredPrompt]);
771
+
772
+ return {
773
+ promptInstall,
774
+ };
775
+ }
776
+ ```
777
+
778
+ #### 5.4 useInstallPrompt - Композиция хуков
779
+
780
+ ```typescript
781
+ // hooks/useInstallPrompt.ts
782
+ 'use client';
783
+
784
+ import { usePlatform } from './usePlatform';
785
+ import { useInstallState } from './useInstallState';
786
+ import { useInstallActions } from './useInstallActions';
787
+
788
+ /**
789
+ * Hook для управления PWA установкой
790
+ *
791
+ * Композиция специализированных хуков:
792
+ * - usePlatform: детекция платформы
793
+ * - useInstallState: состояние установки
794
+ * - useInstallActions: действия установки
795
+ */
796
+ export function useInstallPrompt() {
797
+ const platform = usePlatform();
798
+ const state = useInstallState();
799
+ const actions = useInstallActions(state.deferredPrompt);
800
+
801
+ return {
802
+ // Platform info
803
+ ...platform,
804
+
805
+ // Install state
806
+ ...state,
807
+
808
+ // Install actions
809
+ ...actions,
810
+ };
811
+ }
812
+ ```
813
+
814
+ ### Преимущества
815
+
816
+ - ✅ Четкое разделение ответственности
817
+ - ✅ Каждый хук легко тестировать отдельно
818
+ - ✅ Можно использовать хуки независимо
819
+ - ✅ Улучшенная читаемость кода
820
+
821
+ ### Использование
822
+
823
+ ```typescript
824
+ // Полная функциональность (как раньше)
825
+ const install = useInstallPrompt();
826
+
827
+ // Или использовать отдельно:
828
+ const platform = usePlatform();
829
+ const state = useInstallState();
830
+ const { promptInstall } = useInstallActions(state.deferredPrompt);
831
+ ```
832
+
833
+ ---
834
+
835
+ ## Proposal #6: Упрощение контекстов
836
+
837
+ ### Проблема
838
+
839
+ Текущая архитектура:
840
+ ```typescript
841
+ <PwaProvider>
842
+ {/* PushProvider динамически оборачивает внутри */}
843
+ {children}
844
+ </PwaProvider>
845
+ ```
846
+
847
+ Проблемы:
848
+ - Неочевидная вложенность
849
+ - Трудно отлаживать
850
+ - Нарушает принцип прозрачности
851
+
852
+ ### Решение
853
+
854
+ Явная композиция провайдеров
855
+
856
+ ### Код
857
+
858
+ #### 6.1 Упрощенный PwaInstallProvider
859
+
860
+ ```typescript
861
+ // context/PwaInstallProvider.tsx
862
+ 'use client';
863
+
864
+ import React, { createContext, useContext } from 'react';
865
+ import { useInstallPrompt } from '../hooks/useInstallPrompt';
866
+ import type { InstallOutcome } from '../types';
867
+
868
+ export interface PwaInstallContextValue {
869
+ // Platform
870
+ isIOS: boolean;
871
+ isAndroid: boolean;
872
+ isMobile: boolean;
873
+ isSafari: boolean;
874
+ isChrome: boolean;
875
+
876
+ // State
877
+ isInstalled: boolean;
878
+ canPrompt: boolean;
879
+
880
+ // Actions
881
+ install: () => Promise<InstallOutcome>;
882
+ }
883
+
884
+ const PwaInstallContext = createContext<PwaInstallContextValue | undefined>(undefined);
885
+
886
+ export interface PwaInstallConfig {
887
+ enabled?: boolean;
888
+ }
889
+
890
+ export function PwaInstallProvider({
891
+ children,
892
+ enabled = true,
893
+ }: PwaInstallConfig & { children: React.ReactNode }) {
894
+ const prompt = useInstallPrompt();
895
+
896
+ // If not enabled, pass-through
897
+ if (!enabled) {
898
+ return <>{children}</>;
899
+ }
900
+
901
+ const value: PwaInstallContextValue = {
902
+ isIOS: prompt.isIOS,
903
+ isAndroid: prompt.isAndroid,
904
+ isMobile: prompt.isMobile,
905
+ isSafari: prompt.isSafari,
906
+ isChrome: prompt.isChrome,
907
+ isInstalled: prompt.isInstalled,
908
+ canPrompt: prompt.canPrompt,
909
+ install: prompt.promptInstall,
910
+ };
911
+
912
+ return (
913
+ <PwaInstallContext.Provider value={value}>
914
+ {children}
915
+ </PwaInstallContext.Provider>
916
+ );
917
+ }
918
+
919
+ export function useInstall(): PwaInstallContextValue {
920
+ const context = useContext(PwaInstallContext);
921
+
922
+ if (context === undefined) {
923
+ throw new Error('useInstall must be used within <PwaInstallProvider>');
924
+ }
925
+
926
+ return context;
927
+ }
928
+ ```
929
+
930
+ #### 6.2 Композитный PwaProvider (обратная совместимость)
931
+
932
+ ```typescript
933
+ // context/PwaProvider.tsx
934
+ 'use client';
935
+
936
+ import React from 'react';
937
+ import { PwaInstallProvider, type PwaInstallConfig } from './PwaInstallProvider';
938
+ import { PushProvider } from './PushContext';
939
+ import { A2HSHint } from '../components/A2HSHint';
940
+ import type { PushNotificationOptions } from '../types';
941
+
942
+ export interface PwaConfig extends PwaInstallConfig {
943
+ showInstallHint?: boolean;
944
+ resetAfterDays?: number | null;
945
+ delayMs?: number;
946
+ logo?: string;
947
+ pushNotifications?: PushNotificationOptions & {
948
+ delayMs?: number;
949
+ resetAfterDays?: number;
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Композитный PWA Provider
955
+ *
956
+ * Комбинирует:
957
+ * - PwaInstallProvider (установка PWA)
958
+ * - PushProvider (push уведомления)
959
+ * - A2HSHint (UI подсказка)
960
+ *
961
+ * @deprecated Используйте явную композицию для лучшего контроля
962
+ */
963
+ export function PwaProvider({
964
+ children,
965
+ enabled = true,
966
+ showInstallHint = true,
967
+ resetAfterDays,
968
+ delayMs,
969
+ logo,
970
+ pushNotifications,
971
+ }: PwaConfig & { children: React.ReactNode }) {
972
+ // Deprecation warning
973
+ if (process.env.NODE_ENV === 'development') {
974
+ console.warn(
975
+ '[PwaProvider] This component will be deprecated in v3.0. ' +
976
+ 'Use explicit composition with PwaInstallProvider and PwaPushProvider instead. ' +
977
+ 'See: https://docs.djangocfg.com/pwa/migration'
978
+ );
979
+ }
980
+
981
+ if (!enabled) {
982
+ return <>{children}</>;
983
+ }
984
+
985
+ // Explicit composition
986
+ let content = (
987
+ <PwaInstallProvider enabled={enabled}>
988
+ {children}
989
+ {showInstallHint && (
990
+ <A2HSHint
991
+ resetAfterDays={resetAfterDays}
992
+ delayMs={delayMs}
993
+ logo={logo}
994
+ pushNotifications={pushNotifications}
995
+ />
996
+ )}
997
+ </PwaInstallProvider>
998
+ );
999
+
1000
+ // Wrap with PushProvider if needed
1001
+ if (pushNotifications) {
1002
+ content = <PushProvider {...pushNotifications}>{content}</PushProvider>;
1003
+ }
1004
+
1005
+ return content;
1006
+ }
1007
+
1008
+ // Re-export для обратной совместимости
1009
+ export { useInstall } from './PwaInstallProvider';
1010
+ export { usePush } from './PushContext';
1011
+ ```
1012
+
1013
+ #### 6.3 Рекомендуемое использование
1014
+
1015
+ ```typescript
1016
+ // app/layout.tsx
1017
+
1018
+ // Новый способ (явная композиция)
1019
+ import { PwaInstallProvider, PwaPushProvider, A2HSHint } from '@djangocfg/layouts/snippets';
1020
+
1021
+ <PwaInstallProvider enabled={true}>
1022
+ <PwaPushProvider vapidPublicKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY}>
1023
+ {children}
1024
+ <A2HSHint />
1025
+ </PwaPushProvider>
1026
+ </PwaInstallProvider>
1027
+
1028
+ // Старый способ (обратная совместимость)
1029
+ import { PwaProvider } from '@djangocfg/layouts/snippets';
1030
+
1031
+ <PwaProvider
1032
+ enabled={true}
1033
+ pushNotifications={{
1034
+ vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
1035
+ }}
1036
+ >
1037
+ {children}
1038
+ </PwaProvider>
1039
+ ```
1040
+
1041
+ ### Преимущества
1042
+
1043
+ - ✅ Явная и прозрачная структура
1044
+ - ✅ Легче отлаживать
1045
+ - ✅ Можно использовать только нужные провайдеры
1046
+ - ✅ Обратная совместимость сохранена
1047
+
1048
+ ---
1049
+
1050
+ ## Сравнительная таблица
1051
+
1052
+ | Аспект | До рефакторинга | После рефакторинга |
1053
+ |--------|-----------------|-------------------|
1054
+ | **isStandalone()** | Дублируется в 2 местах | Единая утилита |
1055
+ | **isPWA кеширование** | Нет | sessionStorage |
1056
+ | **Логирование** | Всегда активно | Условное (dev only) |
1057
+ | **VAPID валидация** | Базовая | Полная с понятными ошибками |
1058
+ | **Разделение хуков** | Монолитный useInstallPrompt | Композиция специализированных хуков |
1059
+ | **Архитектура контекстов** | Неявная вложенность | Явная композиция |
1060
+ | **Производительность** | Пересчет при каждом рендере | Мемоизация и кеш |
1061
+ | **Тестируемость** | Сложно | Легко (unit tests) |
1062
+ | **Обратная совместимость** | N/A | Сохранена |
1063
+
1064
+ ---
1065
+
1066
+ ## Roadmap внедрения
1067
+
1068
+ ### Фаза 1: Критические улучшения (неделя 1)
1069
+
1070
+ 1. ✅ Создать `utils/platform.ts` с `isStandalone()`
1071
+ 2. ✅ Мигрировать `useIsPWA` и `useInstallPrompt`
1072
+ 3. ✅ Создать `utils/logger.ts`
1073
+ 4. ✅ Заменить все `consola` на `pwaLogger`
1074
+ 5. ✅ Создать `utils/vapid.ts` с валидацией
1075
+ 6. ✅ Обновить `usePushNotifications`
1076
+
1077
+ ### Фаза 2: Рефакторинг архитектуры (неделя 2-3)
1078
+
1079
+ 1. ✅ Разделить useInstallPrompt на:
1080
+ - usePlatform
1081
+ - useInstallState
1082
+ - useInstallActions
1083
+ 2. ✅ Обновить тесты
1084
+ 3. ✅ Создать PwaInstallProvider
1085
+ 4. ✅ Обновить PwaProvider с deprecation warnings
1086
+
1087
+ ### Фаза 3: Документация и миграция (неделя 4)
1088
+
1089
+ 1. ✅ Обновить README.md
1090
+ 2. ✅ Создать MIGRATION.md
1091
+ 3. ✅ Добавить примеры
1092
+ 4. ✅ Обновить TypeScript типы
1093
+
1094
+ ---
1095
+
1096
+ ## Контрольный список для ревью
1097
+
1098
+ - [ ] Код соответствует TypeScript best practices
1099
+ - [ ] Все функции имеют JSDoc комментарии
1100
+ - [ ] Добавлены unit тесты
1101
+ - [ ] Обратная совместимость проверена
1102
+ - [ ] Производительность не ухудшилась
1103
+ - [ ] Документация обновлена
1104
+ - [ ] Примеры работают
1105
+ - [ ] Нет breaking changes (или они документированы)
1106
+
1107
+ ---
1108
+
1109
+ **Конец документа**