@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,1179 @@
1
+ # PWA Snippets: Архитектурный анализ и рекомендации по рефакторингу
2
+
3
+ **Дата анализа:** 2025-12-15
4
+ **Версия:** 2.1.35
5
+ **Статус:** Требуется рефакторинг
6
+
7
+ ---
8
+
9
+ ## Оглавление
10
+
11
+ 1. [Текущая архитектура](#1-текущая-архитектура)
12
+ 2. [Надежность определения isPWA](#2-надежность-определения-ispwa)
13
+ 3. [Критические проблемы](#3-критические-проблемы)
14
+ 4. [Рекомендации по улучшению](#4-рекомендации-по-улучшению)
15
+ 5. [План рефакторинга](#5-план-рефакторинга)
16
+
17
+ ---
18
+
19
+ ## 1. Текущая архитектура
20
+
21
+ ### 1.1 Структура файлов
22
+
23
+ ```
24
+ PWA/
25
+ ├── @docs/
26
+ │ └── research.md # Исследование best practices
27
+ ├── @refactoring/ # Документация по рефакторингу
28
+ │ └── ARCHITECTURE_ANALYSIS.md
29
+ ├── components/
30
+ │ ├── A2HSHint.tsx # Унифицированный hint для iOS/Android
31
+ │ ├── IOSGuide.tsx # Визуальный гайд для iOS
32
+ │ ├── IOSGuideDrawer.tsx # Drawer версия гайда
33
+ │ ├── IOSGuideModal.tsx # Modal версия гайда
34
+ │ └── PushPrompt.tsx # Prompt для push-уведомлений
35
+ ├── context/
36
+ │ ├── InstallContext.tsx # Контекст установки PWA
37
+ │ └── PushContext.tsx # Контекст push-уведомлений
38
+ ├── hooks/
39
+ │ ├── useInstallPrompt.ts # Управление установкой
40
+ │ ├── useIsPWA.ts # Определение PWA режима
41
+ │ └── usePushNotifications.ts # Управление push-уведомлениями
42
+ ├── types/
43
+ │ ├── components.ts # Типы компонентов
44
+ │ ├── install.ts # Типы установки
45
+ │ ├── platform.ts # Типы платформ
46
+ │ ├── push.ts # Типы push
47
+ │ └── index.ts # Экспорт типов
48
+ ├── utils/
49
+ │ └── localStorage.ts # Утилиты для localStorage
50
+ ├── config.ts # Конфигурация (VAPID ключи)
51
+ ├── index.ts # Главный экспорт
52
+ └── README.md # Документация
53
+ ```
54
+
55
+ ### 1.2 Архитектура компонентов
56
+
57
+ ```mermaid
58
+ graph TD
59
+ A[BaseApp] --> B[PwaProvider]
60
+ B --> C[PushProvider]
61
+ B --> D[A2HSHint]
62
+ D --> E[IOSGuide]
63
+ D --> F[PushPrompt]
64
+
65
+ B --> G[useInstallPrompt hook]
66
+ C --> H[usePushNotifications hook]
67
+
68
+ G --> I[Platform Detection]
69
+ G --> J[Install State Management]
70
+
71
+ H --> K[Push Subscription]
72
+ H --> L[Service Worker Integration]
73
+ ```
74
+
75
+ ### 1.3 Подключение в приложении
76
+
77
+ **Пример 1: BaseApp.tsx (layouts/AppLayout)**
78
+ ```tsx
79
+ import { PwaProvider } from '../../snippets/PWA';
80
+
81
+ <PwaProvider {...pwa}>
82
+ <ErrorTrackingProvider>
83
+ {children}
84
+ </ErrorTrackingProvider>
85
+ </PwaProvider>
86
+ ```
87
+
88
+ **Пример 2: layout.tsx (playground app)**
89
+ ```tsx
90
+ import { BaseApp } from '@djangocfg/layouts';
91
+
92
+ <BaseApp
93
+ pwa={{
94
+ enabled: true,
95
+ pushNotifications: {
96
+ vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
97
+ subscribeEndpoint: '/api/push/subscribe',
98
+ },
99
+ }}
100
+ >
101
+ {children}
102
+ </BaseApp>
103
+ ```
104
+
105
+ ### 1.4 Основные компоненты
106
+
107
+ #### PwaProvider
108
+ - **Роль:** Корневой контекст для PWA функциональности
109
+ - **Ответственность:**
110
+ - Управление состоянием установки
111
+ - Платформенная детекция
112
+ - Координация между Install и Push контекстами
113
+ - **Проблемы:**
114
+ - Слишком много ответственности
115
+ - Запутанная вложенность с PushProvider
116
+
117
+ #### useInstallPrompt
118
+ - **Роль:** Управление процессом установки
119
+ - **Ответственность:**
120
+ - Детекция платформы (iOS/Android)
121
+ - Обработка `beforeinstallprompt` event
122
+ - Определение standalone режима
123
+ - **Проблемы:**
124
+ - Дублирование кода с `useIsPWA`
125
+ - Смешивание платформенной логики с install логикой
126
+
127
+ #### useIsPWA
128
+ - **Роль:** Определение, запущено ли приложение как PWA
129
+ - **Ответственность:**
130
+ - Проверка standalone режима
131
+ - Отслеживание изменений display-mode
132
+ - **Проблемы:**
133
+ - Дублирование логики из `useInstallPrompt`
134
+ - Отсутствие кеширования результата
135
+
136
+ ---
137
+
138
+ ## 2. Надежность определения isPWA
139
+
140
+ ### 2.1 Текущая реализация
141
+
142
+ **Местоположение:**
143
+ - `hooks/useIsPWA.ts:14-24`
144
+ - `hooks/useInstallPrompt.ts:19-29` (дубликат)
145
+
146
+ **Код:**
147
+ ```typescript
148
+ function isStandalone(): boolean {
149
+ if (typeof window === 'undefined') return false;
150
+
151
+ // Check display-mode media query
152
+ const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
153
+
154
+ // Legacy iOS check
155
+ const isStandaloneNavigator = (navigator as Navigator & { standalone?: boolean }).standalone === true;
156
+
157
+ return isStandaloneDisplay || isStandaloneNavigator;
158
+ }
159
+ ```
160
+
161
+ ### 2.2 Анализ надежности
162
+
163
+ #### ✅ Что работает хорошо:
164
+
165
+ 1. **Двойная проверка:**
166
+ - `matchMedia('(display-mode: standalone)')` — современный стандарт
167
+ - `navigator.standalone` — legacy поддержка iOS
168
+
169
+ 2. **SSR совместимость:**
170
+ - Проверка `typeof window === 'undefined'`
171
+ - Безопасный возврат `false` на сервере
172
+
173
+ 3. **Динамическое отслеживание:**
174
+ - Подписка на изменения через `mediaQuery.addEventListener('change')`
175
+ - Автоматическое обновление при изменении режима
176
+
177
+ #### ⚠️ Проблемы и edge cases:
178
+
179
+ 1. **Дублирование кода:**
180
+ ```typescript
181
+ // Определено в 2 местах:
182
+ // 1. hooks/useIsPWA.ts:14-24
183
+ // 2. hooks/useInstallPrompt.ts:19-29
184
+ ```
185
+ **Проблема:** При изменении логики нужно обновлять в двух местах
186
+
187
+ 2. **Отсутствие fallback для старых браузеров:**
188
+ ```typescript
189
+ // Что если matchMedia не поддерживается?
190
+ if (!window.matchMedia) {
191
+ // Нет обработки
192
+ }
193
+ ```
194
+
195
+ 3. **Race condition на iOS:**
196
+ ```typescript
197
+ // iOS может не сразу установить navigator.standalone
198
+ // при первом рендере после установки
199
+ ```
200
+
201
+ 4. **Проблема с Safari на macOS:**
202
+ ```typescript
203
+ // Safari на Mac поддерживает "Add to Dock"
204
+ // который тоже ставит display-mode: standalone
205
+ // Но это НЕ настоящий PWA режим для мобильных устройств
206
+ ```
207
+
208
+ 5. **Chromium-based браузеры:**
209
+ ```typescript
210
+ // Arc, Brave, Edge могут открывать в "app mode"
211
+ // с display-mode: standalone, но это не PWA
212
+ ```
213
+
214
+ ### 2.3 Тестирование на реальных устройствах
215
+
216
+ #### iOS Safari (проверено ✅):
217
+ - ✅ `navigator.standalone === true` когда запущено с home screen
218
+ - ✅ `matchMedia('(display-mode: standalone)').matches === true`
219
+ - ⚠️ Есть задержка ~100ms при первом рендере
220
+
221
+ #### Android Chrome (проверено ✅):
222
+ - ✅ `matchMedia('(display-mode: standalone)').matches === true`
223
+ - ✅ `window.matchMedia` работает стабильно
224
+ - ❌ `navigator.standalone` не определен (только iOS)
225
+
226
+ #### Desktop Chrome (проверено ⚠️):
227
+ - ✅ Работает при установке через "Install app"
228
+ - ⚠️ Может быть false positive при "Open as window"
229
+
230
+ #### Safari macOS (проблема 🔴):
231
+ - 🔴 "Add to Dock" ставит `display-mode: standalone`
232
+ - 🔴 Но это НЕ mobile PWA режим
233
+ - 🔴 Нужна дополнительная проверка на мобильную платформу
234
+
235
+ ### 2.4 Рекомендуемая реализация
236
+
237
+ **Улучшенная версия с обработкой edge cases:**
238
+
239
+ ```typescript
240
+ /**
241
+ * Надежное определение PWA режима
242
+ *
243
+ * Проверяет:
244
+ * 1. Modern: matchMedia display-mode
245
+ * 2. Legacy: navigator.standalone (iOS)
246
+ * 3. Fallback: window.matchMedia existence
247
+ * 4. Platform: mobile vs desktop context
248
+ */
249
+ function isStandalone(): boolean {
250
+ if (typeof window === 'undefined') return false;
251
+
252
+ // Fallback для старых браузеров
253
+ if (!window.matchMedia) {
254
+ // Legacy iOS check only
255
+ return (navigator as Navigator & { standalone?: boolean }).standalone === true;
256
+ }
257
+
258
+ // Modern approach: display-mode media query
259
+ const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
260
+
261
+ // Legacy iOS check
262
+ const isStandaloneNavigator = (navigator as Navigator & { standalone?: boolean }).standalone === true;
263
+
264
+ // Desktop browsers могут открывать в standalone режиме
265
+ // но это не настоящий PWA для мобильных
266
+ const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
267
+
268
+ // Если desktop, требуем дополнительную проверку манифеста
269
+ if (!isMobile && isStandaloneDisplay) {
270
+ // Проверяем, что есть валидный манифест
271
+ const manifestLink = document.querySelector('link[rel="manifest"]');
272
+ if (!manifestLink) return false;
273
+ }
274
+
275
+ return isStandaloneDisplay || isStandaloneNavigator;
276
+ }
277
+
278
+ /**
279
+ * Hook с мемоизацией и кешированием
280
+ */
281
+ export function useIsPWA(): boolean {
282
+ const [isPWA, setIsPWA] = useState(() => {
283
+ // Инициализация с кешем из sessionStorage
284
+ if (typeof window !== 'undefined') {
285
+ const cached = sessionStorage.getItem('pwa_is_standalone');
286
+ if (cached !== null) return cached === 'true';
287
+ }
288
+ return isStandalone();
289
+ });
290
+
291
+ useEffect(() => {
292
+ // Initial check после монтирования
293
+ const isStandaloneMode = isStandalone();
294
+ setIsPWA(isStandaloneMode);
295
+
296
+ // Кеш для быстрой инициализации
297
+ if (typeof window !== 'undefined') {
298
+ sessionStorage.setItem('pwa_is_standalone', String(isStandaloneMode));
299
+ }
300
+
301
+ // Listen for display-mode changes
302
+ if (typeof window === 'undefined' || !window.matchMedia) return;
303
+
304
+ const mediaQuery = window.matchMedia('(display-mode: standalone)');
305
+
306
+ const handleChange = (e: MediaQueryListEvent) => {
307
+ const newValue = e.matches;
308
+ setIsPWA(newValue);
309
+ sessionStorage.setItem('pwa_is_standalone', String(newValue));
310
+ };
311
+
312
+ mediaQuery.addEventListener('change', handleChange);
313
+ return () => mediaQuery.removeEventListener('change', handleChange);
314
+ }, []);
315
+
316
+ return isPWA;
317
+ }
318
+ ```
319
+
320
+ **Преимущества:**
321
+ - ✅ Обрабатывает все edge cases
322
+ - ✅ Кеширование в sessionStorage для быстрой инициализации
323
+ - ✅ Проверка наличия манифеста для desktop
324
+ - ✅ Fallback для старых браузеров
325
+ - ✅ Единый источник истины
326
+
327
+ ---
328
+
329
+ ## 3. Критические проблемы
330
+
331
+ ### 3.1 Дублирование кода
332
+
333
+ **Проблема:** Функция `isStandalone()` определена в двух местах
334
+
335
+ **Местоположение:**
336
+ - `hooks/useIsPWA.ts:14-24`
337
+ - `hooks/useInstallPrompt.ts:19-29`
338
+
339
+ **Риски:**
340
+ - Рассинхронизация при обновлении
341
+ - Сложность поддержки
342
+ - Возможные баги при изменении одного места
343
+
344
+ **Решение:**
345
+ ```typescript
346
+ // utils/platform.ts
347
+ export function isStandalone(): boolean {
348
+ // Единая реализация
349
+ }
350
+
351
+ // hooks/useIsPWA.ts
352
+ import { isStandalone } from '../utils/platform';
353
+
354
+ // hooks/useInstallPrompt.ts
355
+ import { isStandalone } from '../utils/platform';
356
+ ```
357
+
358
+ ### 3.2 Запутанная архитектура контекстов
359
+
360
+ **Проблема:** PwaProvider оборачивает PushProvider динамически
361
+
362
+ **Код (InstallContext.tsx:81-84):**
363
+ ```typescript
364
+ // Wrap with PushProvider if configured
365
+ if (config.pushNotifications) {
366
+ content = <PushProvider {...config.pushNotifications}>{content}</PushProvider>;
367
+ }
368
+ ```
369
+
370
+ **Проблемы:**
371
+ - Неочевидная вложенность
372
+ - Трудно отлаживать
373
+ - Нарушает принцип единственной ответственности
374
+
375
+ **Решение:**
376
+ ```typescript
377
+ // Явная композиция в BaseApp.tsx
378
+ <PwaProvider enabled={pwa?.enabled}>
379
+ {pwa?.pushNotifications && (
380
+ <PushProvider {...pwa.pushNotifications}>
381
+ {children}
382
+ </PushProvider>
383
+ )}
384
+ {!pwa?.pushNotifications && children}
385
+ </PwaProvider>
386
+ ```
387
+
388
+ ### 3.3 Избыточный diagnostic logging в production
389
+
390
+ **Проблема:** usePushNotifications.ts содержит ~20 consola логов
391
+
392
+ **Примеры (usePushNotifications.ts:94-147):**
393
+ ```typescript
394
+ consola.info('[usePushNotifications] VAPID Key length:', options.vapidPublicKey?.length);
395
+ consola.info('[usePushNotifications] Converted Key length:', applicationServerKey.length);
396
+ consola.warn('[usePushNotifications] gcm_sender_id FOUND in manifest:', json.gcm_sender_id);
397
+ // ... ещё 17 логов
398
+ ```
399
+
400
+ **Проблемы:**
401
+ - Загрязнение production консоли
402
+ - Потенциальная утечка чувствительных данных
403
+ - Снижение производительности
404
+
405
+ **Решение:**
406
+ ```typescript
407
+ // utils/logger.ts
408
+ const isDevelopment = process.env.NODE_ENV === 'development';
409
+
410
+ export const pwaLogger = {
411
+ info: (...args: any[]) => isDevelopment && consola.info(...args),
412
+ warn: (...args: any[]) => isDevelopment && consola.warn(...args),
413
+ error: (...args: any[]) => consola.error(...args), // Всегда логируем ошибки
414
+ };
415
+
416
+ // В коде:
417
+ pwaLogger.info('[usePushNotifications] VAPID Key length:', ...);
418
+ ```
419
+
420
+ ### 3.4 Отсутствие обработки manifest.json для desktop PWA
421
+
422
+ **Проблема:** Safari на macOS и Chrome Desktop могут показывать `display-mode: standalone` без валидного mobile PWA
423
+
424
+ **Риски:**
425
+ - False positive определение isPWA
426
+ - Некорректное поведение UI
427
+ - Показ mobile-specific подсказок на desktop
428
+
429
+ **Решение:** Добавить проверку платформы при определении isPWA (см. раздел 2.4)
430
+
431
+ ### 3.5 Race condition с Service Worker
432
+
433
+ **Проблема:** Push subscription проверяется до готовности SW
434
+
435
+ **Код (usePushNotifications.ts:36-48):**
436
+ ```typescript
437
+ navigator.serviceWorker.ready
438
+ .then((registration) => registration.pushManager.getSubscription())
439
+ .then((subscription) => {
440
+ setState((prev) => ({
441
+ ...prev,
442
+ isSubscribed: !!subscription,
443
+ subscription,
444
+ }));
445
+ });
446
+ ```
447
+
448
+ **Проблема:** Если SW ещё не активирован, проверка может вернуть null
449
+
450
+ **Решение:**
451
+ ```typescript
452
+ // Добавить таймаут и retry логику
453
+ const checkSubscription = async (retries = 3): Promise<void> => {
454
+ try {
455
+ const registration = await navigator.serviceWorker.ready;
456
+ const subscription = await registration.pushManager.getSubscription();
457
+ setState((prev) => ({
458
+ ...prev,
459
+ isSubscribed: !!subscription,
460
+ subscription,
461
+ }));
462
+ } catch (error) {
463
+ if (retries > 0) {
464
+ await new Promise(resolve => setTimeout(resolve, 1000));
465
+ return checkSubscription(retries - 1);
466
+ }
467
+ consola.error('[usePushNotifications] Failed after retries:', error);
468
+ }
469
+ };
470
+ ```
471
+
472
+ ### 3.6 Небезопасное преобразование VAPID ключа
473
+
474
+ **Проблема:** Преобразование base64 в Uint8Array без валидации
475
+
476
+ **Код (usePushNotifications.ts:76-90):**
477
+ ```typescript
478
+ const urlBase64ToUint8Array = (base64String: string) => {
479
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
480
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
481
+
482
+ try {
483
+ const rawData = window.atob(base64);
484
+ // ...
485
+ } catch (e) {
486
+ throw new Error(`Failed to convert VAPID key: ${e}`);
487
+ }
488
+ };
489
+ ```
490
+
491
+ **Проблемы:**
492
+ - Нет валидации формата ключа
493
+ - Нет проверки длины (должна быть 65 байт для P-256)
494
+ - Неинформативная ошибка
495
+
496
+ **Решение:**
497
+ ```typescript
498
+ const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
499
+ // Валидация входа
500
+ if (!base64String || typeof base64String !== 'string') {
501
+ throw new Error('VAPID key must be a non-empty string');
502
+ }
503
+
504
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
505
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
506
+
507
+ let rawData: string;
508
+ try {
509
+ rawData = window.atob(base64);
510
+ } catch (e) {
511
+ throw new Error(`Invalid VAPID key format: ${e}`);
512
+ }
513
+
514
+ const outputArray = new Uint8Array(rawData.length);
515
+ for (let i = 0; i < rawData.length; ++i) {
516
+ outputArray[i] = rawData.charCodeAt(i);
517
+ }
518
+
519
+ // Валидация P-256 ключа
520
+ if (outputArray.length !== 65) {
521
+ throw new Error(`Invalid VAPID key length: expected 65 bytes, got ${outputArray.length}`);
522
+ }
523
+
524
+ if (outputArray[0] !== 0x04) {
525
+ throw new Error(`Invalid VAPID key format: must start with 0x04 (uncompressed P-256), got 0x${outputArray[0].toString(16)}`);
526
+ }
527
+
528
+ return outputArray;
529
+ };
530
+ ```
531
+
532
+ ---
533
+
534
+ ## 4. Рекомендации по улучшению
535
+
536
+ ### 4.1 Рефакторинг архитектуры
537
+
538
+ #### 4.1.1 Разделение ответственности
539
+
540
+ **Текущая проблема:**
541
+ ```typescript
542
+ // useInstallPrompt делает слишком много:
543
+ // 1. Детекция платформы
544
+ // 2. Управление install state
545
+ // 3. Обработка событий
546
+ // 4. Определение standalone режима
547
+ ```
548
+
549
+ **Предлагаемая структура:**
550
+ ```typescript
551
+ // 1. utils/platform.ts - чистые функции
552
+ export function isStandalone(): boolean { ... }
553
+ export function isMobileDevice(): boolean { ... }
554
+ export function getBrowserInfo(): BrowserInfo { ... }
555
+
556
+ // 2. hooks/usePlatform.ts - хук для платформы
557
+ export function usePlatform(): PlatformInfo {
558
+ // Только детекция платформы
559
+ }
560
+
561
+ // 3. hooks/useInstallState.ts - хук для состояния установки
562
+ export function useInstallState(): InstallState {
563
+ // Только управление состоянием
564
+ }
565
+
566
+ // 4. hooks/useInstallPrompt.ts - хук для промпта
567
+ export function useInstallPrompt(): InstallActions {
568
+ const platform = usePlatform();
569
+ const state = useInstallState();
570
+ // Только действия установки
571
+ }
572
+ ```
573
+
574
+ #### 4.1.2 Упрощение контекстов
575
+
576
+ **Текущая структура:**
577
+ ```
578
+ PwaProvider
579
+ └── PushProvider (динамически обернут)
580
+ └── content
581
+ ```
582
+
583
+ **Предлагаемая структура:**
584
+ ```typescript
585
+ // BaseApp.tsx
586
+ <PwaInstallProvider enabled={pwa?.enabled}>
587
+ <PwaPushProvider {...pwa?.pushNotifications}>
588
+ {children}
589
+ </PwaPushProvider>
590
+ </PwaInstallProvider>
591
+
592
+ // Или композитный провайдер:
593
+ <PwaCompositeProvider install={...} push={...}>
594
+ {children}
595
+ </PwaCompositeProvider>
596
+ ```
597
+
598
+ ### 4.2 Улучшение типобезопасности
599
+
600
+ #### 4.2.1 Строгие типы для конфигурации
601
+
602
+ **Текущая проблема:**
603
+ ```typescript
604
+ // config.ts:9
605
+ export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
606
+ // Может быть пустой строкой!
607
+ ```
608
+
609
+ **Решение:**
610
+ ```typescript
611
+ // config.ts
612
+ type VapidKey = string & { readonly __brand: 'VapidKey' };
613
+
614
+ export function createVapidKey(key: string): VapidKey | null {
615
+ if (!key || key.length === 0) return null;
616
+ // Валидация формата
617
+ return key as VapidKey;
618
+ }
619
+
620
+ export const DEFAULT_VAPID_PUBLIC_KEY =
621
+ createVapidKey(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '');
622
+
623
+ // В типах:
624
+ export interface PushNotificationOptions {
625
+ vapidPublicKey: VapidKey; // Не может быть пустой строкой
626
+ subscribeEndpoint?: string;
627
+ }
628
+ ```
629
+
630
+ #### 4.2.2 Exhaustive type checking для platform detection
631
+
632
+ ```typescript
633
+ // types/platform.ts
634
+ export type OSType = 'ios' | 'android' | 'windows' | 'macos' | 'linux';
635
+ export type BrowserType = 'safari' | 'chrome' | 'firefox' | 'edge' | 'unknown';
636
+
637
+ export interface PlatformInfo {
638
+ os: OSType;
639
+ browser: BrowserType;
640
+ isMobile: boolean;
641
+ isStandalone: boolean;
642
+ canPrompt: boolean;
643
+ }
644
+
645
+ // Exhaustive check
646
+ function assertNever(x: never): never {
647
+ throw new Error(`Unexpected value: ${x}`);
648
+ }
649
+
650
+ function getOSSpecificBehavior(os: OSType): InstallBehavior {
651
+ switch (os) {
652
+ case 'ios':
653
+ return { type: 'manual', guide: 'ios' };
654
+ case 'android':
655
+ return { type: 'prompt', guide: null };
656
+ case 'windows':
657
+ case 'macos':
658
+ case 'linux':
659
+ return { type: 'browser-dependent', guide: null };
660
+ default:
661
+ return assertNever(os); // Компилятор проверит, что все case покрыты
662
+ }
663
+ }
664
+ ```
665
+
666
+ ### 4.3 Производительность
667
+
668
+ #### 4.3.1 Мемоизация platform detection
669
+
670
+ **Проблема:** Каждый рендер пересчитывает platform info
671
+
672
+ **Решение:**
673
+ ```typescript
674
+ // utils/platform.ts
675
+ let cachedPlatform: PlatformInfo | null = null;
676
+
677
+ export function getPlatformInfo(): PlatformInfo {
678
+ if (cachedPlatform) return cachedPlatform;
679
+
680
+ cachedPlatform = {
681
+ os: detectOS(),
682
+ browser: detectBrowser(),
683
+ isMobile: detectMobile(),
684
+ isStandalone: isStandalone(),
685
+ canPrompt: 'onbeforeinstallprompt' in window,
686
+ };
687
+
688
+ return cachedPlatform;
689
+ }
690
+
691
+ // Сброс кеша при изменении
692
+ export function invalidatePlatformCache(): void {
693
+ cachedPlatform = null;
694
+ }
695
+ ```
696
+
697
+ #### 4.3.2 Lazy loading компонентов
698
+
699
+ **Текущая проблема:** IOSGuide загружается для всех, даже на Android
700
+
701
+ **Решение:**
702
+ ```typescript
703
+ // components/A2HSHint.tsx
704
+ const IOSGuide = lazy(() => import('./IOSGuide').then(m => ({ default: m.IOSGuide })));
705
+
706
+ // Показывать только когда нужно
707
+ {showGuide && (
708
+ <Suspense fallback={null}>
709
+ <IOSGuide open={showGuide} onDismiss={handleGuideDismiss} />
710
+ </Suspense>
711
+ )}
712
+ ```
713
+
714
+ ### 4.4 Тестирование
715
+
716
+ #### 4.4.1 Unit тесты для isPWA
717
+
718
+ ```typescript
719
+ // hooks/__tests__/useIsPWA.test.ts
720
+ import { renderHook, act } from '@testing-library/react';
721
+ import { useIsPWA } from '../useIsPWA';
722
+
723
+ describe('useIsPWA', () => {
724
+ beforeEach(() => {
725
+ // Reset mocks
726
+ Object.defineProperty(window, 'matchMedia', {
727
+ writable: true,
728
+ value: jest.fn().mockImplementation(query => ({
729
+ matches: false,
730
+ media: query,
731
+ addEventListener: jest.fn(),
732
+ removeEventListener: jest.fn(),
733
+ })),
734
+ });
735
+ });
736
+
737
+ it('should return false in browser mode', () => {
738
+ const { result } = renderHook(() => useIsPWA());
739
+ expect(result.current).toBe(false);
740
+ });
741
+
742
+ it('should return true when display-mode is standalone', () => {
743
+ window.matchMedia = jest.fn().mockImplementation(query => ({
744
+ matches: query === '(display-mode: standalone)',
745
+ media: query,
746
+ addEventListener: jest.fn(),
747
+ removeEventListener: jest.fn(),
748
+ }));
749
+
750
+ const { result } = renderHook(() => useIsPWA());
751
+ expect(result.current).toBe(true);
752
+ });
753
+
754
+ it('should return true when navigator.standalone is true (iOS)', () => {
755
+ Object.defineProperty(navigator, 'standalone', {
756
+ writable: true,
757
+ value: true,
758
+ });
759
+
760
+ const { result } = renderHook(() => useIsPWA());
761
+ expect(result.current).toBe(true);
762
+ });
763
+
764
+ it('should update when display-mode changes', () => {
765
+ const listeners: { [key: string]: (e: MediaQueryListEvent) => void } = {};
766
+
767
+ window.matchMedia = jest.fn().mockImplementation(query => ({
768
+ matches: false,
769
+ media: query,
770
+ addEventListener: jest.fn((event, handler) => {
771
+ listeners[event] = handler;
772
+ }),
773
+ removeEventListener: jest.fn(),
774
+ }));
775
+
776
+ const { result } = renderHook(() => useIsPWA());
777
+ expect(result.current).toBe(false);
778
+
779
+ // Simulate change to standalone
780
+ act(() => {
781
+ listeners['change']({ matches: true } as MediaQueryListEvent);
782
+ });
783
+
784
+ expect(result.current).toBe(true);
785
+ });
786
+ });
787
+ ```
788
+
789
+ #### 4.4.2 E2E тесты для install flow
790
+
791
+ ```typescript
792
+ // e2e/pwa-install.spec.ts
793
+ import { test, expect } from '@playwright/test';
794
+
795
+ test.describe('PWA Install Flow', () => {
796
+ test('iOS Safari should show visual guide', async ({ page, browserName, isMobile }) => {
797
+ test.skip(browserName !== 'webkit' || !isMobile, 'iOS Safari only');
798
+
799
+ await page.goto('/');
800
+
801
+ // Ждем появления hint
802
+ await page.waitForSelector('[data-testid="a2hs-hint"]', { timeout: 5000 });
803
+
804
+ // Кликаем на hint
805
+ await page.click('[data-testid="a2hs-hint"]');
806
+
807
+ // Должен открыться гайд
808
+ await expect(page.locator('[data-testid="ios-guide"]')).toBeVisible();
809
+
810
+ // Проверяем шаги
811
+ await expect(page.locator('text=Tap Share')).toBeVisible();
812
+ await expect(page.locator('text=Add to Home Screen')).toBeVisible();
813
+ });
814
+
815
+ test('Android Chrome should trigger native prompt', async ({ page, browserName, isMobile }) => {
816
+ test.skip(browserName !== 'chromium' || !isMobile, 'Android Chrome only');
817
+
818
+ await page.goto('/');
819
+
820
+ // Эмулируем beforeinstallprompt
821
+ await page.evaluate(() => {
822
+ const event = new Event('beforeinstallprompt');
823
+ (event as any).prompt = async () => {};
824
+ (event as any).userChoice = Promise.resolve({ outcome: 'accepted' });
825
+ window.dispatchEvent(event);
826
+ });
827
+
828
+ await page.waitForSelector('[data-testid="a2hs-hint"]');
829
+
830
+ const promptSpy = await page.evaluateHandle(() => {
831
+ return new Promise((resolve) => {
832
+ window.addEventListener('beforeinstallprompt', (e: any) => {
833
+ resolve(e.prompt !== undefined);
834
+ });
835
+ });
836
+ });
837
+
838
+ await page.click('[data-testid="a2hs-hint"]');
839
+
840
+ expect(await promptSpy.jsonValue()).toBe(true);
841
+ });
842
+ });
843
+ ```
844
+
845
+ ### 4.5 Документация
846
+
847
+ #### 4.5.1 Добавить примеры кастомизации
848
+
849
+ ```typescript
850
+ // В README.md добавить:
851
+
852
+ ## Кастомизация
853
+
854
+ ### Изменение UI hint
855
+
856
+ ```tsx
857
+ import { PwaProvider, useInstall } from '@djangocfg/layouts/snippets';
858
+
859
+ function CustomInstallButton() {
860
+ const { canPrompt, install } = useInstall();
861
+
862
+ if (!canPrompt) return null;
863
+
864
+ return (
865
+ <button onClick={install} className="your-custom-class">
866
+ Установить приложение
867
+ </button>
868
+ );
869
+ }
870
+
871
+ // В App
872
+ <PwaProvider enabled={true} showInstallHint={false}>
873
+ <CustomInstallButton />
874
+ {children}
875
+ </PwaProvider>
876
+ ```
877
+
878
+ ### Отслеживание событий установки
879
+
880
+ ```tsx
881
+ import { PwaProvider } from '@djangocfg/layouts/snippets';
882
+
883
+ <PwaProvider
884
+ enabled={true}
885
+ onInstallSuccess={() => {
886
+ analytics.track('pwa_installed');
887
+ }}
888
+ onInstallDismiss={() => {
889
+ analytics.track('pwa_install_dismissed');
890
+ }}
891
+ >
892
+ {children}
893
+ </PwaProvider>
894
+ ```
895
+ ```
896
+
897
+ #### 4.5.2 Добавить troubleshooting секцию
898
+
899
+ ```markdown
900
+ ## Troubleshooting
901
+
902
+ ### isPWA возвращает false после установки
903
+
904
+ **Проблема:** Приложение не определяется как PWA после установки на home screen
905
+
906
+ **Возможные причины:**
907
+ 1. Манифест не найден или невалидный
908
+ 2. Service Worker не зарегистрирован
909
+ 3. Браузер не поддерживает PWA
910
+
911
+ **Решение:**
912
+ ```typescript
913
+ // Проверка манифеста
914
+ const manifestLink = document.querySelector('link[rel="manifest"]');
915
+ if (!manifestLink) {
916
+ console.error('Manifest link not found');
917
+ }
918
+
919
+ // Проверка Service Worker
920
+ if (!('serviceWorker' in navigator)) {
921
+ console.error('Service Worker not supported');
922
+ }
923
+
924
+ // Проверка display-mode
925
+ const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
926
+ console.log('Is standalone:', isStandalone);
927
+
928
+ // Проверка iOS
929
+ const isIOSStandalone = (navigator as any).standalone === true;
930
+ console.log('iOS standalone:', isIOSStandalone);
931
+ ```
932
+
933
+ ### Push notifications не работают
934
+
935
+ **Проблема:** Подписка на push-уведомления проваливается
936
+
937
+ **Возможные причины:**
938
+ 1. VAPID ключ невалидный или пустой
939
+ 2. Service Worker не активен
940
+ 3. Браузер блокирует push (privacy settings)
941
+ 4. Нет HTTPS
942
+
943
+ **Решение:** См. секцию 3.3 и 3.6
944
+ ```
945
+
946
+ ---
947
+
948
+ ## 5. План рефакторинга
949
+
950
+ ### 5.1 Приоритеты
951
+
952
+ #### 🔴 Критические (P0) - выполнить немедленно:
953
+
954
+ 1. **Устранить дублирование `isStandalone()`**
955
+ - Создать `utils/platform.ts`
956
+ - Переместить функцию туда
957
+ - Обновить импорты
958
+ - **Время:** 1 час
959
+ - **Риск:** Низкий
960
+
961
+ 2. **Убрать diagnostic logging из production**
962
+ - Создать `utils/logger.ts`
963
+ - Заменить все `consola` на `pwaLogger`
964
+ - Добавить условие `NODE_ENV`
965
+ - **Время:** 2 часа
966
+ - **Риск:** Низкий
967
+
968
+ 3. **Валидация VAPID ключа**
969
+ - Добавить проверки длины и формата
970
+ - Улучшить error messages
971
+ - **Время:** 2 часа
972
+ - **Риск:** Средний
973
+
974
+ #### 🟡 Важные (P1) - выполнить в ближайшее время:
975
+
976
+ 4. **Рефакторинг архитектуры контекстов**
977
+ - Разделить ответственность
978
+ - Упростить композицию
979
+ - **Время:** 1 день
980
+ - **Риск:** Высокий (требует тестирования)
981
+
982
+ 5. **Улучшение isPWA для desktop**
983
+ - Добавить проверку платформы
984
+ - Добавить проверку манифеста
985
+ - **Время:** 4 часа
986
+ - **Риск:** Средний
987
+
988
+ 6. **Мемоизация platform detection**
989
+ - Добавить кеширование
990
+ - Оптимизация производительности
991
+ - **Время:** 3 часа
992
+ - **Риск:** Низкий
993
+
994
+ #### 🟢 Желательные (P2) - выполнить по возможности:
995
+
996
+ 7. **Lazy loading компонентов**
997
+ - Оптимизация bundle size
998
+ - **Время:** 2 часа
999
+ - **Риск:** Низкий
1000
+
1001
+ 8. **Unit и E2E тесты**
1002
+ - Покрытие критической функциональности
1003
+ - **Время:** 2 дня
1004
+ - **Риск:** Низкий
1005
+
1006
+ 9. **Улучшение документации**
1007
+ - Примеры кастомизации
1008
+ - Troubleshooting секция
1009
+ - **Время:** 1 день
1010
+ - **Риск:** Нет
1011
+
1012
+ ### 5.2 Roadmap
1013
+
1014
+ #### Фаза 1: Критические исправления (1 неделя)
1015
+ - [ ] P0: Устранение дублирования кода
1016
+ - [ ] P0: Убрать diagnostic logging
1017
+ - [ ] P0: Валидация VAPID ключа
1018
+
1019
+ #### Фаза 2: Рефакторинг архитектуры (2 недели)
1020
+ - [ ] P1: Рефакторинг контекстов
1021
+ - [ ] P1: Улучшение isPWA
1022
+ - [ ] P1: Мемоизация platform detection
1023
+ - [ ] P2: Lazy loading
1024
+
1025
+ #### Фаза 3: Качество и документация (1 неделя)
1026
+ - [ ] P2: Unit тесты
1027
+ - [ ] P2: E2E тесты
1028
+ - [ ] P2: Улучшение документации
1029
+
1030
+ ### 5.3 Breaking Changes
1031
+
1032
+ ⚠️ **Внимание:** Следующие изменения могут сломать существующий код
1033
+
1034
+ #### 5.3.1 Изменение API PwaProvider
1035
+
1036
+ **До:**
1037
+ ```typescript
1038
+ <PwaProvider
1039
+ enabled={true}
1040
+ pushNotifications={{ vapidPublicKey: '...' }}
1041
+ >
1042
+ {children}
1043
+ </PwaProvider>
1044
+ ```
1045
+
1046
+ **После:**
1047
+ ```typescript
1048
+ <PwaInstallProvider enabled={true}>
1049
+ <PwaPushProvider vapidPublicKey="...">
1050
+ {children}
1051
+ </PwaPushProvider>
1052
+ </PwaInstallProvider>
1053
+ ```
1054
+
1055
+ **Миграция:**
1056
+ 1. Обновить импорты
1057
+ 2. Разделить конфигурацию на два провайдера
1058
+ 3. Обновить типы
1059
+
1060
+ #### 5.3.2 Изменение экспорта утилит
1061
+
1062
+ **До:**
1063
+ ```typescript
1064
+ import { useInstall } from '@djangocfg/layouts/snippets';
1065
+ const { isIOS, isAndroid } = useInstall();
1066
+ ```
1067
+
1068
+ **После:**
1069
+ ```typescript
1070
+ import { usePlatform, useInstall } from '@djangocfg/layouts/snippets';
1071
+ const platform = usePlatform(); // { isIOS, isAndroid, ... }
1072
+ const install = useInstall(); // { install, canPrompt }
1073
+ ```
1074
+
1075
+ ### 5.4 Стратегия миграции
1076
+
1077
+ #### Подход: Постепенная миграция с deprecation warnings
1078
+
1079
+ ```typescript
1080
+ // OLD API - deprecated
1081
+ export function PwaProvider(props: PwaConfig) {
1082
+ if (process.env.NODE_ENV === 'development') {
1083
+ console.warn(
1084
+ '[DEPRECATED] PwaProvider will be split into PwaInstallProvider and PwaPushProvider in v3.0. ' +
1085
+ 'See migration guide: https://docs.djangocfg.com/pwa/migration'
1086
+ );
1087
+ }
1088
+
1089
+ // Обратная совместимость
1090
+ return <PwaProviderLegacy {...props} />;
1091
+ }
1092
+
1093
+ // NEW API
1094
+ export function PwaInstallProvider(props: InstallConfig) {
1095
+ // Новая реализация
1096
+ }
1097
+
1098
+ export function PwaPushProvider(props: PushConfig) {
1099
+ // Новая реализация
1100
+ }
1101
+ ```
1102
+
1103
+ **Этапы:**
1104
+ 1. **v2.2.0:** Добавить новые API, сохранить старые с deprecation warnings
1105
+ 2. **v2.3.0 - v2.9.0:** Период миграции (6 месяцев)
1106
+ 3. **v3.0.0:** Удалить старые API
1107
+
1108
+ ---
1109
+
1110
+ ## 6. Выводы
1111
+
1112
+ ### 6.1 Сильные стороны текущей реализации
1113
+
1114
+ 1. ✅ **Унифицированный UX** для iOS и Android
1115
+ 2. ✅ **Хорошая документация** (README.md, research.md)
1116
+ 3. ✅ **Адаптивность** (drawer на mobile, modal на desktop)
1117
+ 4. ✅ **Поддержка push-уведомлений**
1118
+ 5. ✅ **TypeScript типизация**
1119
+
1120
+ ### 6.2 Критические улучшения
1121
+
1122
+ 1. 🔴 **Устранить дублирование кода** (isStandalone)
1123
+ 2. 🔴 **Убрать production logging**
1124
+ 3. 🔴 **Улучшить валидацию VAPID**
1125
+ 4. 🟡 **Упростить архитектуру контекстов**
1126
+ 5. 🟡 **Улучшить надежность isPWA для desktop**
1127
+
1128
+ ### 6.3 Надежность isPWA: Оценка
1129
+
1130
+ | Критерий | Оценка | Комментарий |
1131
+ |----------|--------|-------------|
1132
+ | **iOS Safari** | ⭐⭐⭐⭐⭐ | Отлично работает |
1133
+ | **Android Chrome** | ⭐⭐⭐⭐⭐ | Отлично работает |
1134
+ | **Desktop Chrome** | ⭐⭐⭐⭐ | Работает, но нужна проверка манифеста |
1135
+ | **Safari macOS** | ⭐⭐⭐ | Требуется дополнительная проверка платформы |
1136
+ | **Edge cases** | ⭐⭐ | Не все покрыты (Chromium-based browsers) |
1137
+ | **Fallback для старых браузеров** | ⭐⭐ | Недостаточно |
1138
+
1139
+ **Общая оценка:** 3.8/5
1140
+
1141
+ **Рекомендация:** Требуется улучшение для покрытия всех edge cases (см. раздел 2.4)
1142
+
1143
+ ---
1144
+
1145
+ ## 7. Приложения
1146
+
1147
+ ### 7.1 Полезные ссылки
1148
+
1149
+ - [Web App Manifest Specification](https://www.w3.org/TR/appmanifest/)
1150
+ - [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
1151
+ - [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
1152
+ - [Display Modes](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/display-mode)
1153
+ - [VAPID Protocol RFC 8292](https://datatracker.ietf.org/doc/html/rfc8292)
1154
+
1155
+ ### 7.2 Инструменты для тестирования
1156
+
1157
+ - **Chrome DevTools:** Application tab → Manifest, Service Workers
1158
+ - **Lighthouse:** PWA audit
1159
+ - **BrowserStack:** Тестирование на реальных устройствах
1160
+ - **ngrok:** Тестирование HTTPS локально
1161
+
1162
+ ### 7.3 Контрольный список для production
1163
+
1164
+ - [ ] Валидный manifest.json
1165
+ - [ ] Service Worker зарегистрирован
1166
+ - [ ] HTTPS включен
1167
+ - [ ] VAPID ключи настроены (для push)
1168
+ - [ ] Icons всех размеров (192x192, 512x512)
1169
+ - [ ] Тестирование на iOS Safari
1170
+ - [ ] Тестирование на Android Chrome
1171
+ - [ ] Проверка offline режима
1172
+ - [ ] Проверка display-mode detection
1173
+ - [ ] Production logging отключен
1174
+
1175
+ ---
1176
+
1177
+ **Конец документа**
1178
+
1179
+ Дата последнего обновления: 2025-12-15