@djangocfg/layouts 2.1.35 → 2.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/layouts/AppLayout/BaseApp.tsx +31 -25
- package/src/layouts/shared/types.ts +36 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
- package/src/snippets/PWA/@docs/research.md +576 -0
- package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
- package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
- package/src/snippets/PWA/@refactoring/README.md +204 -0
- package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
- package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
- package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
- package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
- package/src/snippets/PWA/@refactoring2/README.md +85 -0
- package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
- package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
- package/src/snippets/PWA/README.md +387 -0
- package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
- package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
- package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
- package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
- package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
- package/src/snippets/PWA/config.ts +20 -0
- package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
- package/src/snippets/PWA/context/InstallContext.tsx +118 -0
- package/src/snippets/PWA/context/PushContext.tsx +156 -0
- package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
- package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
- package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
- package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
- package/src/snippets/PWA/index.ts +95 -0
- package/src/snippets/PWA/types/components.ts +101 -0
- package/src/snippets/PWA/types/index.ts +26 -0
- package/src/snippets/PWA/types/install.ts +38 -0
- package/src/snippets/PWA/types/platform.ts +29 -0
- package/src/snippets/PWA/types/push.ts +21 -0
- package/src/snippets/PWA/utils/localStorage.ts +203 -0
- package/src/snippets/PWA/utils/logger.ts +149 -0
- package/src/snippets/PWA/utils/platform.ts +151 -0
- package/src/snippets/PWA/utils/vapid.ts +226 -0
- package/src/snippets/index.ts +30 -0
|
@@ -0,0 +1,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
|
+
**Конец документа**
|