@djangocfg/layouts 2.1.36 → 2.1.38

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 (64) hide show
  1. package/README.md +204 -18
  2. package/package.json +5 -5
  3. package/src/components/errors/index.ts +9 -0
  4. package/src/components/errors/types.ts +38 -0
  5. package/src/layouts/AppLayout/AppLayout.tsx +33 -45
  6. package/src/layouts/AppLayout/BaseApp.tsx +105 -28
  7. package/src/layouts/AuthLayout/AuthContext.tsx +7 -1
  8. package/src/layouts/AuthLayout/OAuthProviders.tsx +1 -10
  9. package/src/layouts/AuthLayout/OTPForm.tsx +1 -0
  10. package/src/layouts/PrivateLayout/PrivateLayout.tsx +1 -1
  11. package/src/layouts/PublicLayout/PublicLayout.tsx +1 -1
  12. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
  13. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +1 -1
  14. package/src/layouts/_components/UserMenu.tsx +1 -1
  15. package/src/layouts/index.ts +1 -1
  16. package/src/layouts/types/index.ts +47 -0
  17. package/src/layouts/types/layout.types.ts +61 -0
  18. package/src/layouts/types/providers.types.ts +65 -0
  19. package/src/layouts/types/ui.types.ts +103 -0
  20. package/src/snippets/Analytics/index.ts +1 -0
  21. package/src/snippets/Analytics/types.ts +10 -0
  22. package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
  23. package/src/snippets/PWAInstall/@docs/README.md +92 -0
  24. package/src/snippets/PWAInstall/@docs/research/ios-android-install-flows.md +576 -0
  25. package/src/snippets/PWAInstall/README.md +185 -0
  26. package/src/snippets/PWAInstall/components/A2HSHint.tsx +227 -0
  27. package/src/snippets/PWAInstall/components/DesktopGuide.tsx +229 -0
  28. package/src/snippets/PWAInstall/components/IOSGuide.tsx +29 -0
  29. package/src/snippets/PWAInstall/components/IOSGuideDrawer.tsx +101 -0
  30. package/src/snippets/PWAInstall/components/IOSGuideModal.tsx +101 -0
  31. package/src/snippets/PWAInstall/context/InstallContext.tsx +102 -0
  32. package/src/snippets/PWAInstall/hooks/useInstallPrompt.ts +167 -0
  33. package/src/snippets/PWAInstall/hooks/useIsPWA.ts +115 -0
  34. package/src/snippets/PWAInstall/index.ts +76 -0
  35. package/src/snippets/PWAInstall/types/components.ts +95 -0
  36. package/src/snippets/PWAInstall/types/config.ts +22 -0
  37. package/src/snippets/PWAInstall/types/index.ts +26 -0
  38. package/src/snippets/PWAInstall/types/install.ts +38 -0
  39. package/src/snippets/PWAInstall/types/platform.ts +29 -0
  40. package/src/snippets/PWAInstall/utils/localStorage.ts +181 -0
  41. package/src/snippets/PWAInstall/utils/logger.ts +149 -0
  42. package/src/snippets/PWAInstall/utils/platform.ts +151 -0
  43. package/src/snippets/PushNotifications/@docs/README.md +191 -0
  44. package/src/snippets/PushNotifications/@docs/guides/django-integration.md +648 -0
  45. package/src/snippets/PushNotifications/@docs/guides/service-worker.md +467 -0
  46. package/src/snippets/PushNotifications/@docs/guides/vapid-setup.md +352 -0
  47. package/src/snippets/PushNotifications/README.md +328 -0
  48. package/src/snippets/PushNotifications/components/PushPrompt.tsx +165 -0
  49. package/src/snippets/PushNotifications/config.ts +20 -0
  50. package/src/snippets/PushNotifications/context/DjangoPushContext.tsx +190 -0
  51. package/src/snippets/PushNotifications/hooks/useDjangoPush.ts +259 -0
  52. package/src/snippets/PushNotifications/hooks/usePushNotifications.ts +209 -0
  53. package/src/snippets/PushNotifications/index.ts +87 -0
  54. package/src/snippets/PushNotifications/types/config.ts +28 -0
  55. package/src/snippets/PushNotifications/types/index.ts +9 -0
  56. package/src/snippets/PushNotifications/types/push.ts +21 -0
  57. package/src/snippets/PushNotifications/utils/localStorage.ts +60 -0
  58. package/src/snippets/PushNotifications/utils/logger.ts +149 -0
  59. package/src/snippets/PushNotifications/utils/platform.ts +151 -0
  60. package/src/snippets/PushNotifications/utils/vapid.ts +226 -0
  61. package/src/snippets/index.ts +55 -0
  62. package/src/layouts/shared/index.ts +0 -21
  63. package/src/layouts/shared/types.ts +0 -211
  64. /package/src/layouts/{shared → types}/README.md +0 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Component Props Types
3
+ */
4
+
5
+ import type { PlatformInfo } from './platform';
6
+ import type { InstallOutcome } from './install';
7
+
8
+ /**
9
+ * Install context type
10
+ */
11
+ export interface InstallContextType {
12
+ // State
13
+ platform: PlatformInfo;
14
+ isInstalled: boolean;
15
+ canPrompt: boolean;
16
+
17
+ // iOS Guide
18
+ showIOSGuide: boolean;
19
+ setShowIOSGuide: (show: boolean) => void;
20
+
21
+ // Actions
22
+ promptInstall: () => Promise<InstallOutcome>;
23
+ dismissIOSGuide: () => void;
24
+ }
25
+
26
+ /**
27
+ * Install manager props
28
+ */
29
+ export interface InstallManagerProps {
30
+ /**
31
+ * Delay before showing iOS guide (ms)
32
+ * @default 2000
33
+ */
34
+ delayMs?: number;
35
+
36
+ /**
37
+ * Number of days before re-showing dismissed guide
38
+ * @default 7
39
+ */
40
+ resetDays?: number;
41
+
42
+ /**
43
+ * Custom class name for install button
44
+ */
45
+ buttonClassName?: string;
46
+
47
+ /**
48
+ * Custom button text
49
+ */
50
+ buttonText?: string;
51
+
52
+ /**
53
+ * Force show install UI (ignores platform detection)
54
+ * Useful for testing on desktop in development
55
+ * @default false
56
+ */
57
+ forceShow?: boolean;
58
+
59
+ /**
60
+ * Callback when install is successful
61
+ */
62
+ onInstallSuccess?: () => void;
63
+
64
+ /**
65
+ * Callback when install is dismissed
66
+ */
67
+ onInstallDismiss?: () => void;
68
+ }
69
+
70
+ /**
71
+ * Android install button props
72
+ */
73
+ export interface AndroidInstallButtonProps {
74
+ onInstall: () => Promise<InstallOutcome>;
75
+ className?: string;
76
+ text?: string;
77
+ }
78
+
79
+ /**
80
+ * iOS guide modal props
81
+ */
82
+ export interface IOSGuideModalProps {
83
+ onDismiss: () => void;
84
+ open?: boolean;
85
+ }
86
+
87
+ /**
88
+ * Install step for iOS guide
89
+ */
90
+ export interface InstallStep {
91
+ number: number;
92
+ title: string;
93
+ icon: React.ComponentType<{ className?: string }>;
94
+ description: string;
95
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * PWA Install Configuration Types
3
+ *
4
+ * Configuration for Progressive Web App installation features
5
+ */
6
+
7
+ export interface PwaInstallConfig {
8
+ /** Enable PWA installation features */
9
+ enabled?: boolean;
10
+
11
+ /** Show A2HS (Add to Home Screen) hint (enabled by default when enabled is true) */
12
+ showInstallHint?: boolean;
13
+
14
+ /** Number of days before re-showing dismissed hint (null = never show again) */
15
+ resetAfterDays?: number | null;
16
+
17
+ /** Delay before showing hint (ms) */
18
+ delayMs?: number;
19
+
20
+ /** App logo URL to display in hint */
21
+ logo?: string;
22
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * PWA Install Types - Central Export
3
+ */
4
+
5
+ // Configuration
6
+ export type { PwaInstallConfig } from './config';
7
+
8
+ // Platform
9
+ export type { PlatformInfo } from './platform';
10
+
11
+ // Install
12
+ export type {
13
+ InstallPromptState,
14
+ BeforeInstallPromptEvent,
15
+ InstallOutcome,
16
+ IOSGuideState,
17
+ } from './install';
18
+
19
+ // Components
20
+ export type {
21
+ InstallContextType,
22
+ InstallManagerProps,
23
+ AndroidInstallButtonProps,
24
+ IOSGuideModalProps,
25
+ InstallStep,
26
+ } from './components';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * PWA Installation Types
3
+ */
4
+
5
+ /**
6
+ * Install prompt state
7
+ */
8
+ export interface InstallPromptState {
9
+ isIOS: boolean;
10
+ isAndroid: boolean;
11
+ isSafari: boolean;
12
+ isChrome: boolean;
13
+ isInstalled: boolean;
14
+ canPrompt: boolean;
15
+ deferredPrompt: BeforeInstallPromptEvent | null;
16
+ }
17
+
18
+ /**
19
+ * BeforeInstallPrompt event (Android Chrome)
20
+ */
21
+ export interface BeforeInstallPromptEvent extends Event {
22
+ prompt: () => Promise<void>;
23
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
24
+ }
25
+
26
+ /**
27
+ * Install outcome
28
+ */
29
+ export type InstallOutcome = 'accepted' | 'dismissed' | null;
30
+
31
+ /**
32
+ * iOS guide dismissal state
33
+ */
34
+ export interface IOSGuideState {
35
+ dismissed: boolean;
36
+ dismissedAt: number | null;
37
+ shouldShow: boolean;
38
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Platform Detection Types
3
+ */
4
+
5
+ /**
6
+ * Platform detection result
7
+ */
8
+ export interface PlatformInfo {
9
+ // Operating System
10
+ isIOS: boolean;
11
+ isAndroid: boolean;
12
+ isDesktop: boolean;
13
+
14
+ // Browser
15
+ isSafari: boolean;
16
+ isChrome: boolean;
17
+ isEdge: boolean;
18
+ isFirefox: boolean;
19
+
20
+ // Installation State
21
+ isStandalone: boolean;
22
+
23
+ // Capability
24
+ canPrompt: boolean;
25
+
26
+ // Composite checks
27
+ shouldShowAndroidPrompt: boolean;
28
+ shouldShowIOSGuide: boolean;
29
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * LocalStorage utilities for PWA install state persistence
3
+ */
4
+
5
+ import type { IOSGuideState } from '../types';
6
+
7
+ /**
8
+ * Storage keys
9
+ */
10
+ export const STORAGE_KEYS = {
11
+ IOS_GUIDE_DISMISSED: 'pwa_ios_guide_dismissed_at',
12
+ APP_INSTALLED: 'pwa_app_installed',
13
+ A2HS_DISMISSED: 'pwa_a2hs_dismissed_at',
14
+ } as const;
15
+
16
+ /**
17
+ * Check if iOS guide was dismissed recently
18
+ * @param resetDays Number of days before re-showing (default: 7)
19
+ */
20
+ export function isDismissedRecently(resetDays: number = 7): boolean {
21
+ if (typeof window === 'undefined') return false;
22
+
23
+ try {
24
+ const dismissed = localStorage.getItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED);
25
+ if (!dismissed) return false;
26
+
27
+ const dismissedAt = parseInt(dismissed, 10);
28
+ const now = Date.now();
29
+ const daysSinceDismissed = (now - dismissedAt) / (1000 * 60 * 60 * 24);
30
+
31
+ return daysSinceDismissed < resetDays;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Mark iOS guide as dismissed
39
+ */
40
+ export function markIOSGuideDismissed(): void {
41
+ if (typeof window === 'undefined') return;
42
+
43
+ try {
44
+ localStorage.setItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED, Date.now().toString());
45
+ } catch {
46
+ // Fail silently if localStorage is not available
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Clear iOS guide dismissal
52
+ */
53
+ export function clearIOSGuideDismissal(): void {
54
+ if (typeof window === 'undefined') return;
55
+
56
+ try {
57
+ localStorage.removeItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED);
58
+ } catch {
59
+ // Fail silently
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get iOS guide state
65
+ */
66
+ export function getIOSGuideState(resetDays: number = 7): IOSGuideState {
67
+ if (typeof window === 'undefined') {
68
+ return {
69
+ dismissed: false,
70
+ dismissedAt: null,
71
+ shouldShow: false,
72
+ };
73
+ }
74
+
75
+ try {
76
+ const dismissed = localStorage.getItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED);
77
+ if (!dismissed) {
78
+ return {
79
+ dismissed: false,
80
+ dismissedAt: null,
81
+ shouldShow: true,
82
+ };
83
+ }
84
+
85
+ const dismissedAt = parseInt(dismissed, 10);
86
+ const now = Date.now();
87
+ const daysSinceDismissed = (now - dismissedAt) / (1000 * 60 * 60 * 24);
88
+
89
+ return {
90
+ dismissed: true,
91
+ dismissedAt,
92
+ shouldShow: daysSinceDismissed >= resetDays,
93
+ };
94
+ } catch {
95
+ return {
96
+ dismissed: false,
97
+ dismissedAt: null,
98
+ shouldShow: false,
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Mark app as installed
105
+ */
106
+ export function markAppInstalled(): void {
107
+ if (typeof window === 'undefined') return;
108
+
109
+ try {
110
+ localStorage.setItem(STORAGE_KEYS.APP_INSTALLED, 'true');
111
+ // Clear dismissal state when app is installed
112
+ clearIOSGuideDismissal();
113
+ } catch {
114
+ // Fail silently
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Check if app is marked as installed
120
+ */
121
+ export function isAppInstalled(): boolean {
122
+ if (typeof window === 'undefined') return false;
123
+
124
+ try {
125
+ return localStorage.getItem(STORAGE_KEYS.APP_INSTALLED) === 'true';
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Check if A2HS hint was dismissed recently
133
+ * @param resetDays Number of days before re-showing (default: 3)
134
+ */
135
+ export function isA2HSDismissedRecently(resetDays: number = 3): boolean {
136
+ return isDismissedRecentlyHelper(resetDays, STORAGE_KEYS.A2HS_DISMISSED);
137
+ }
138
+
139
+ /**
140
+ * Mark A2HS hint as dismissed
141
+ */
142
+ export function markA2HSDismissed(): void {
143
+ if (typeof window === 'undefined') return;
144
+ try {
145
+ localStorage.setItem(STORAGE_KEYS.A2HS_DISMISSED, Date.now().toString());
146
+ } catch {
147
+ // Fail silently
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Helper: Check if a key was dismissed recently
153
+ * @internal
154
+ */
155
+ function isDismissedRecentlyHelper(resetDays: number, key: string): boolean {
156
+ if (typeof window === 'undefined') return false;
157
+ try {
158
+ const dismissed = localStorage.getItem(key);
159
+ if (!dismissed) return false;
160
+ const dismissedAt = parseInt(dismissed, 10);
161
+ const daysSince = (Date.now() - dismissedAt) / (1000 * 60 * 60 * 24);
162
+ return daysSince < resetDays;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Clear all PWA install data
170
+ */
171
+ export function clearAllPWAInstallData(): void {
172
+ if (typeof window === 'undefined') return;
173
+
174
+ try {
175
+ localStorage.removeItem(STORAGE_KEYS.IOS_GUIDE_DISMISSED);
176
+ localStorage.removeItem(STORAGE_KEYS.APP_INSTALLED);
177
+ localStorage.removeItem(STORAGE_KEYS.A2HS_DISMISSED);
178
+ } catch {
179
+ // Fail silently
180
+ }
181
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * PWA Logger with Conditional Logging
3
+ *
4
+ * Provides logging utilities that respect environment and debug settings:
5
+ * - In production: Only errors are logged
6
+ * - In development: All levels are logged
7
+ * - Debug mode: Can be enabled in production via localStorage
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { pwaLogger } from '../utils/logger';
12
+ *
13
+ * pwaLogger.info('Info message'); // Only in dev
14
+ * pwaLogger.warn('Warning message'); // Only in dev
15
+ * pwaLogger.error('Error message'); // Always logged
16
+ * pwaLogger.debug('Debug message'); // Only when debug enabled
17
+ * ```
18
+ *
19
+ * Enable debug mode in production:
20
+ * ```typescript
21
+ * import { enablePWADebug } from '../utils/logger';
22
+ * enablePWADebug();
23
+ * // or in console:
24
+ * localStorage.setItem('pwa_debug', 'true');
25
+ * ```
26
+ */
27
+
28
+ import { consola } from 'consola';
29
+
30
+ const isDevelopment = process.env.NODE_ENV === 'development';
31
+
32
+ /**
33
+ * Check if debug mode is enabled via localStorage
34
+ */
35
+ function isDebugEnabled(): boolean {
36
+ if (typeof window === 'undefined') return false;
37
+ try {
38
+ return localStorage.getItem('pwa_debug') === 'true';
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * PWA Logger instance with conditional logging
46
+ */
47
+ export const pwaLogger = {
48
+ /**
49
+ * Info level logging
50
+ * Only logs in development or when debug is enabled
51
+ */
52
+ info: (...args: Parameters<typeof consola.info>): void => {
53
+ if (isDevelopment || isDebugEnabled()) {
54
+ consola.info(...args);
55
+ }
56
+ },
57
+
58
+ /**
59
+ * Warning level logging
60
+ * Only logs in development or when debug is enabled
61
+ */
62
+ warn: (...args: Parameters<typeof consola.warn>): void => {
63
+ if (isDevelopment || isDebugEnabled()) {
64
+ consola.warn(...args);
65
+ }
66
+ },
67
+
68
+ /**
69
+ * Error level logging
70
+ * Always logs (production + development)
71
+ */
72
+ error: (...args: Parameters<typeof consola.error>): void => {
73
+ consola.error(...args);
74
+ },
75
+
76
+ /**
77
+ * Debug level logging
78
+ * Only logs when debug is explicitly enabled
79
+ */
80
+ debug: (...args: Parameters<typeof consola.debug>): void => {
81
+ if (isDebugEnabled()) {
82
+ consola.debug(...args);
83
+ }
84
+ },
85
+
86
+ /**
87
+ * Success level logging
88
+ * Only logs in development or when debug is enabled
89
+ */
90
+ success: (...args: Parameters<typeof consola.success>): void => {
91
+ if (isDevelopment || isDebugEnabled()) {
92
+ consola.success(...args);
93
+ }
94
+ },
95
+ };
96
+
97
+ /**
98
+ * Enable debug mode
99
+ *
100
+ * This allows seeing debug logs in production.
101
+ * Call this function or set localStorage manually:
102
+ * `localStorage.setItem('pwa_debug', 'true')`
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * import { enablePWADebug } from '@djangocfg/layouts/snippets';
107
+ * enablePWADebug();
108
+ * // Reload page to see debug logs
109
+ * ```
110
+ */
111
+ export function enablePWADebug(): void {
112
+ if (typeof window !== 'undefined') {
113
+ try {
114
+ localStorage.setItem('pwa_debug', 'true');
115
+ consola.info('[PWA] Debug mode enabled. Reload page to see debug logs.');
116
+ } catch (e) {
117
+ consola.error('[PWA] Failed to enable debug mode:', e);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Disable debug mode
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * import { disablePWADebug } from '@djangocfg/layouts/snippets';
128
+ * disablePWADebug();
129
+ * ```
130
+ */
131
+ export function disablePWADebug(): void {
132
+ if (typeof window !== 'undefined') {
133
+ try {
134
+ localStorage.removeItem('pwa_debug');
135
+ consola.info('[PWA] Debug mode disabled.');
136
+ } catch (e) {
137
+ consola.error('[PWA] Failed to disable debug mode:', e);
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Check if debug mode is currently active
144
+ *
145
+ * @returns true if debug mode is enabled
146
+ */
147
+ export function isPWADebugEnabled(): boolean {
148
+ return isDebugEnabled();
149
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Platform Detection Utilities
3
+ *
4
+ * Centralized utilities for detecting PWA state, platform, and capabilities.
5
+ * Used by hooks to avoid code duplication.
6
+ */
7
+
8
+ /**
9
+ * Check if running as PWA (standalone mode)
10
+ *
11
+ * Checks if the app is running in standalone mode (added to home screen).
12
+ * This is the primary indicator that a PWA has been installed.
13
+ *
14
+ * @returns true if app is running in standalone mode
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * if (isStandalone()) {
19
+ * console.log('Running as PWA');
20
+ * }
21
+ * ```
22
+ */
23
+ export function isStandalone(): boolean {
24
+ if (typeof window === 'undefined') return false;
25
+
26
+ // Fallback for older browsers without matchMedia
27
+ if (!window.matchMedia) {
28
+ // Use legacy iOS check only
29
+ const nav = navigator as Navigator & { standalone?: boolean };
30
+ return nav.standalone === true;
31
+ }
32
+
33
+ // Check display-mode media query (modern approach)
34
+ const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
35
+
36
+ // Legacy iOS check (navigator.standalone)
37
+ const nav = navigator as Navigator & { standalone?: boolean };
38
+ const isStandaloneNavigator = nav.standalone === true;
39
+
40
+ return isStandaloneDisplay || isStandaloneNavigator;
41
+ }
42
+
43
+ /**
44
+ * Check if device is mobile
45
+ *
46
+ * @returns true if device is mobile (iOS, Android, etc.)
47
+ */
48
+ export function isMobileDevice(): boolean {
49
+ if (typeof window === 'undefined') return false;
50
+ return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
51
+ }
52
+
53
+ /**
54
+ * Check if web app manifest exists and is valid
55
+ *
56
+ * @returns true if manifest link exists in document head
57
+ */
58
+ export function hasValidManifest(): boolean {
59
+ if (typeof document === 'undefined') return false;
60
+ const manifestLink = document.querySelector('link[rel="manifest"]');
61
+ return !!manifestLink;
62
+ }
63
+
64
+ /**
65
+ * Reliable check for PWA mode with edge case handling
66
+ *
67
+ * This function provides additional validation for desktop browsers
68
+ * to avoid false positives (e.g., Safari macOS "Add to Dock").
69
+ *
70
+ * For mobile devices, standard standalone check is sufficient.
71
+ * For desktop, additionally validates that a manifest exists.
72
+ *
73
+ * @returns true if app is running as a genuine PWA
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * // Use this for more reliable detection
78
+ * if (isStandaloneReliable()) {
79
+ * console.log('Definitely running as PWA');
80
+ * }
81
+ * ```
82
+ */
83
+ export function isStandaloneReliable(): boolean {
84
+ const standalone = isStandalone();
85
+ if (!standalone) return false;
86
+
87
+ // For mobile devices, standalone check is sufficient
88
+ if (isMobileDevice()) return true;
89
+
90
+ // For desktop browsers, additionally check for valid manifest
91
+ // This prevents false positives like Safari macOS "Add to Dock"
92
+ return hasValidManifest();
93
+ }
94
+
95
+ /**
96
+ * Get display mode from media query
97
+ *
98
+ * @returns Current display mode: 'standalone', 'fullscreen', 'minimal-ui', or 'browser'
99
+ */
100
+ export function getDisplayMode(): 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser' {
101
+ if (typeof window === 'undefined') return 'browser';
102
+
103
+ if (!window.matchMedia) return 'browser';
104
+
105
+ const modes: Array<'standalone' | 'fullscreen' | 'minimal-ui'> = [
106
+ 'fullscreen',
107
+ 'standalone',
108
+ 'minimal-ui',
109
+ ];
110
+
111
+ for (const mode of modes) {
112
+ if (window.matchMedia(`(display-mode: ${mode})`).matches) {
113
+ return mode;
114
+ }
115
+ }
116
+
117
+ return 'browser';
118
+ }
119
+
120
+ /**
121
+ * Create a media query listener for display-mode changes
122
+ *
123
+ * @param callback - Function to call when display mode changes
124
+ * @returns Cleanup function to remove listener
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const cleanup = onDisplayModeChange((isStandalone) => {
129
+ * console.log('Display mode changed:', isStandalone);
130
+ * });
131
+ *
132
+ * // Later: cleanup();
133
+ * ```
134
+ */
135
+ export function onDisplayModeChange(callback: (isStandalone: boolean) => void): () => void {
136
+ if (typeof window === 'undefined' || !window.matchMedia) {
137
+ return () => {}; // No-op cleanup
138
+ }
139
+
140
+ const mediaQuery = window.matchMedia('(display-mode: standalone)');
141
+
142
+ const handleChange = (e: MediaQueryListEvent) => {
143
+ callback(e.matches);
144
+ };
145
+
146
+ mediaQuery.addEventListener('change', handleChange);
147
+
148
+ return () => {
149
+ mediaQuery.removeEventListener('change', handleChange);
150
+ };
151
+ }