@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,101 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * iOS Installation Guide Drawer (Mobile-Optimized)
5
+ *
6
+ * Bottom drawer with swipe gestures for iOS installation guide
7
+ * Better UX for mobile devices
8
+ */
9
+
10
+ import React from 'react';
11
+ import { Share, Check, ArrowUpRight, ArrowDown, CheckCircle } from 'lucide-react';
12
+
13
+ import {
14
+ Drawer,
15
+ DrawerContent,
16
+ DrawerDescription,
17
+ DrawerHeader,
18
+ DrawerTitle,
19
+ Button,
20
+ Card,
21
+ CardContent,
22
+ } from '@djangocfg/ui-nextjs';
23
+
24
+ import type { IOSGuideModalProps, InstallStep } from '../types';
25
+
26
+ const steps: InstallStep[] = [
27
+ {
28
+ number: 1,
29
+ title: 'Tap Share',
30
+ icon: ArrowUpRight,
31
+ description: 'At the bottom of Safari',
32
+ },
33
+ {
34
+ number: 2,
35
+ title: 'Scroll & Tap',
36
+ icon: ArrowDown,
37
+ description: '"Add to Home Screen"',
38
+ },
39
+ {
40
+ number: 3,
41
+ title: 'Confirm',
42
+ icon: CheckCircle,
43
+ description: 'Tap "Add" in top-right',
44
+ },
45
+ ];
46
+
47
+ function StepCard({ step }: { step: InstallStep }) {
48
+ return (
49
+ <Card className="border border-border">
50
+ <CardContent className="p-4">
51
+ <div className="flex items-start gap-3">
52
+ <div
53
+ className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
54
+ style={{ width: '32px', height: '32px' }}
55
+ >
56
+ <span className="text-sm font-semibold">{step.number}</span>
57
+ </div>
58
+
59
+ <div className="flex-1 min-w-0">
60
+ <div className="flex items-center gap-2 mb-1">
61
+ <step.icon className="w-5 h-5 text-primary" />
62
+ <h3 className="font-semibold text-foreground">{step.title}</h3>
63
+ </div>
64
+ <p className="text-sm text-muted-foreground">{step.description}</p>
65
+ </div>
66
+ </div>
67
+ </CardContent>
68
+ </Card>
69
+ );
70
+ }
71
+
72
+ export function IOSGuideDrawer({ onDismiss, open = true }: IOSGuideModalProps) {
73
+ return (
74
+ <Drawer open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
75
+ <DrawerContent>
76
+ <DrawerHeader className="text-left">
77
+ <DrawerTitle className="flex items-center gap-2">
78
+ <Share className="w-5 h-5 text-primary" />
79
+ Add to Home Screen
80
+ </DrawerTitle>
81
+ <DrawerDescription className="text-left">
82
+ Install this app on your iPhone for quick access and a better experience
83
+ </DrawerDescription>
84
+ </DrawerHeader>
85
+
86
+ <div className="space-y-3 p-4">
87
+ {steps.map((step) => (
88
+ <StepCard key={step.number} step={step} />
89
+ ))}
90
+ </div>
91
+
92
+ <div className="p-4 pt-0">
93
+ <Button onClick={onDismiss} variant="default" className="w-full">
94
+ <Check className="w-4 h-4 mr-2" />
95
+ Got It
96
+ </Button>
97
+ </div>
98
+ </DrawerContent>
99
+ </Drawer>
100
+ );
101
+ }
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * iOS Installation Guide Modal
5
+ *
6
+ * Visual step-by-step guide for adding PWA to home screen on iOS Safari
7
+ */
8
+
9
+ import React from 'react';
10
+ import { Share, Check, ArrowUpRight, ArrowDown, CheckCircle } from 'lucide-react';
11
+
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ Button,
20
+ Card,
21
+ CardContent,
22
+ } from '@djangocfg/ui-nextjs';
23
+
24
+ import type { IOSGuideModalProps, InstallStep } from '../types';
25
+
26
+ const steps: InstallStep[] = [
27
+ {
28
+ number: 1,
29
+ title: 'Tap Share',
30
+ icon: ArrowUpRight,
31
+ description: 'At the bottom of Safari',
32
+ },
33
+ {
34
+ number: 2,
35
+ title: 'Scroll & Tap',
36
+ icon: ArrowDown,
37
+ description: '"Add to Home Screen"',
38
+ },
39
+ {
40
+ number: 3,
41
+ title: 'Confirm',
42
+ icon: CheckCircle,
43
+ description: 'Tap "Add" in top-right',
44
+ },
45
+ ];
46
+
47
+ function StepCard({ step }: { step: InstallStep }) {
48
+ return (
49
+ <Card className="border border-border">
50
+ <CardContent className="p-4">
51
+ <div className="flex items-start gap-3">
52
+ <div
53
+ className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
54
+ style={{ width: '32px', height: '32px' }}
55
+ >
56
+ <span className="text-sm font-semibold">{step.number}</span>
57
+ </div>
58
+
59
+ <div className="flex-1 min-w-0">
60
+ <div className="flex items-center gap-2 mb-1">
61
+ <step.icon className="w-5 h-5 text-primary" />
62
+ <h3 className="font-semibold text-foreground">{step.title}</h3>
63
+ </div>
64
+ <p className="text-sm text-muted-foreground">{step.description}</p>
65
+ </div>
66
+ </div>
67
+ </CardContent>
68
+ </Card>
69
+ );
70
+ }
71
+
72
+ export function IOSGuideModal({ onDismiss, open = true }: IOSGuideModalProps) {
73
+ return (
74
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
75
+ <DialogContent className="sm:max-w-md">
76
+ <DialogHeader className="text-left">
77
+ <DialogTitle className="flex items-center gap-2">
78
+ <Share className="w-5 h-5 text-primary" />
79
+ Add to Home Screen
80
+ </DialogTitle>
81
+ <DialogDescription className="text-left">
82
+ Install this app on your iPhone for quick access and a better experience
83
+ </DialogDescription>
84
+ </DialogHeader>
85
+
86
+ <div className="space-y-3 py-4">
87
+ {steps.map((step) => (
88
+ <StepCard key={step.number} step={step} />
89
+ ))}
90
+ </div>
91
+
92
+ <DialogFooter>
93
+ <Button onClick={onDismiss} variant="default" className="w-full">
94
+ <Check className="w-4 h-4 mr-2" />
95
+ Got It
96
+ </Button>
97
+ </DialogFooter>
98
+ </DialogContent>
99
+ </Dialog>
100
+ );
101
+ }
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Push Notification Prompt
5
+ *
6
+ * Shows after PWA is installed to request push notification permission
7
+ * Auto-dismisses after user action or timeout
8
+ */
9
+
10
+ import React, { useState, useEffect } from 'react';
11
+ import { Bell, X } from 'lucide-react';
12
+ import { Button } from '@djangocfg/ui-nextjs';
13
+
14
+ import { usePushNotifications } from '../hooks/usePushNotifications';
15
+ import { isStandalone } from '../utils/platform';
16
+ import { pwaLogger } from '../utils/logger';
17
+ import { markPushDismissed, isPushDismissedRecently } from '../utils/localStorage';
18
+ import type { PushNotificationOptions } from '../types';
19
+
20
+ const DEFAULT_RESET_DAYS = 7;
21
+
22
+ interface PushPromptProps extends PushNotificationOptions {
23
+ /**
24
+ * Only show if PWA is installed
25
+ * @default true
26
+ */
27
+ requirePWA?: boolean;
28
+
29
+ /**
30
+ * Delay before showing prompt (ms)
31
+ * @default 5000
32
+ */
33
+ delayMs?: number;
34
+
35
+ /**
36
+ * Number of days before re-showing dismissed prompt
37
+ * @default 7
38
+ */
39
+ resetAfterDays?: number;
40
+
41
+ /**
42
+ * Callback when push is enabled
43
+ */
44
+ onEnabled?: () => void;
45
+
46
+ /**
47
+ * Callback when push is dismissed
48
+ */
49
+ onDismissed?: () => void;
50
+ }
51
+
52
+ export function PushPrompt({
53
+ vapidPublicKey,
54
+ subscribeEndpoint = '/api/push/subscribe',
55
+ requirePWA = true,
56
+ delayMs = 5000,
57
+ resetAfterDays = DEFAULT_RESET_DAYS,
58
+ onEnabled,
59
+ onDismissed,
60
+ }: PushPromptProps) {
61
+ const { isSupported, permission, isSubscribed, subscribe } = usePushNotifications({
62
+ vapidPublicKey,
63
+ subscribeEndpoint,
64
+ });
65
+
66
+ const [show, setShow] = useState(false);
67
+ const [enabling, setEnabling] = useState(false);
68
+
69
+ // Check if should show
70
+ useEffect(() => {
71
+ if (!isSupported || isSubscribed || permission === 'denied') {
72
+ return;
73
+ }
74
+
75
+ // Check if PWA is installed (standalone mode)
76
+ if (requirePWA && !isStandalone()) {
77
+ return;
78
+ }
79
+
80
+ // Check if previously dismissed
81
+ if (typeof window !== 'undefined') {
82
+ if (isPushDismissedRecently(resetAfterDays)) {
83
+ return; // Still within reset period
84
+ }
85
+ }
86
+
87
+ // Show after delay
88
+ const timer = setTimeout(() => setShow(true), delayMs);
89
+ return () => clearTimeout(timer);
90
+ }, [isSupported, isSubscribed, permission, requirePWA, resetAfterDays, delayMs]);
91
+
92
+ const handleEnable = async () => {
93
+ setEnabling(true);
94
+ try {
95
+ const success = await subscribe();
96
+ if (success) {
97
+ setShow(false);
98
+ onEnabled?.();
99
+ }
100
+ } catch (error) {
101
+ pwaLogger.error('[PushPrompt] Enable failed:', error);
102
+ } finally {
103
+ setEnabling(false);
104
+ }
105
+ };
106
+
107
+ const handleDismiss = () => {
108
+ setShow(false);
109
+ markPushDismissed();
110
+ onDismissed?.();
111
+ };
112
+
113
+ if (!show) return null;
114
+
115
+ return (
116
+ <div className="fixed bottom-4 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 duration-300">
117
+ <div className="bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-lg">
118
+ <div className="flex items-start gap-3">
119
+ {/* Icon */}
120
+ <div className="flex-shrink-0">
121
+ <Bell className="w-5 h-5 text-blue-400" />
122
+ </div>
123
+
124
+ {/* Content */}
125
+ <div className="flex-1 min-w-0">
126
+ <p className="text-sm font-medium text-white mb-1">Enable notifications</p>
127
+ <p className="text-xs text-zinc-400 mb-3">
128
+ Stay updated with important updates and alerts
129
+ </p>
130
+
131
+ {/* Actions */}
132
+ <div className="flex gap-2">
133
+ <Button
134
+ onClick={handleEnable}
135
+ loading={enabling}
136
+ size="sm"
137
+ variant="default"
138
+ >
139
+ Enable
140
+ </Button>
141
+ <Button
142
+ onClick={handleDismiss}
143
+ size="sm"
144
+ variant="ghost"
145
+ >
146
+ Not now
147
+ </Button>
148
+ </div>
149
+ </div>
150
+
151
+ {/* Close button */}
152
+ <Button
153
+ onClick={handleDismiss}
154
+ size="sm"
155
+ variant="ghost"
156
+ className="flex-shrink-0 p-1"
157
+ aria-label="Dismiss"
158
+ >
159
+ <X className="w-4 h-4" />
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * PWA Configuration
3
+ *
4
+ * Centralized constants for PWA functionality.
5
+ *
6
+ * SECURITY NOTE:
7
+ * - VAPID_PRIVATE_KEY should NEVER be in frontend code
8
+ * - Private keys must only exist in backend/API routes
9
+ * - Frontend only needs the public key (NEXT_PUBLIC_* env vars)
10
+ * - VAPID_MAILTO should also remain on backend only
11
+ */
12
+
13
+ // Default VAPID public key (safe to expose in frontend)
14
+ export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
15
+
16
+ // NOTE: VAPID private key and mailto should only exist in:
17
+ // - Backend environment variables (not NEXT_PUBLIC_*)
18
+ // - API route handlers (app/api/push/*)
19
+ // - Service worker generation scripts
20
+ // NEVER import or use private keys in frontend code
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Django Push Context
5
+ *
6
+ * Provider for Django-CFG push notifications integration.
7
+ * Wraps useDjangoPush hook in React context for easy consumption.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * import { DjangoPushProvider, useDjangoPushContext } from '@djangocfg/layouts/PWA';
12
+ *
13
+ * // In layout
14
+ * <DjangoPushProvider vapidPublicKey={process.env.NEXT_PUBLIC_VAPID_KEY}>
15
+ * {children}
16
+ * </DjangoPushProvider>
17
+ *
18
+ * // In component
19
+ * function NotifyButton() {
20
+ * const { subscribe, isSubscribed } = useDjangoPushContext();
21
+ * return <button onClick={subscribe}>Subscribe</button>;
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ import React, { createContext, useContext } from 'react';
27
+ import { useDjangoPush } from '../hooks/useDjangoPush';
28
+ import type { PushNotificationOptions } from '../types';
29
+
30
+ interface DjangoPushContextValue {
31
+ // State
32
+ isSupported: boolean;
33
+ permission: NotificationPermission;
34
+ isSubscribed: boolean;
35
+ subscription: PushSubscription | null;
36
+ isLoading: boolean;
37
+ error: Error | null;
38
+
39
+ // Actions
40
+ subscribe: () => Promise<boolean>;
41
+ unsubscribe: () => Promise<boolean>;
42
+ sendTestPush: (message: { title: string; body: string; url?: string }) => Promise<boolean>;
43
+ }
44
+
45
+ const DjangoPushContext = createContext<DjangoPushContextValue | undefined>(undefined);
46
+
47
+ interface DjangoPushProviderProps extends PushNotificationOptions {
48
+ children: React.ReactNode;
49
+
50
+ /**
51
+ * Auto-subscribe on mount if permission granted
52
+ * @default false
53
+ */
54
+ autoSubscribe?: boolean;
55
+
56
+ /**
57
+ * Callback when subscription created
58
+ */
59
+ onSubscribed?: (subscription: PushSubscription) => void;
60
+
61
+ /**
62
+ * Callback when subscription failed
63
+ */
64
+ onSubscribeError?: (error: Error) => void;
65
+
66
+ /**
67
+ * Callback when unsubscribed
68
+ */
69
+ onUnsubscribed?: () => void;
70
+ }
71
+
72
+ /**
73
+ * Provider for Django push notifications
74
+ */
75
+ export function DjangoPushProvider({
76
+ children,
77
+ vapidPublicKey,
78
+ autoSubscribe = false,
79
+ onSubscribed,
80
+ onSubscribeError,
81
+ onUnsubscribed,
82
+ }: DjangoPushProviderProps) {
83
+ const djangoPush = useDjangoPush({
84
+ vapidPublicKey,
85
+ autoSubscribe,
86
+ onSubscribed,
87
+ onSubscribeError,
88
+ onUnsubscribed,
89
+ });
90
+
91
+ return <DjangoPushContext.Provider value={djangoPush}>{children}</DjangoPushContext.Provider>;
92
+ }
93
+
94
+ /**
95
+ * Hook to access Django push context
96
+ */
97
+ export function useDjangoPushContext(): DjangoPushContextValue {
98
+ const context = useContext(DjangoPushContext);
99
+
100
+ if (context === undefined) {
101
+ throw new Error('useDjangoPushContext must be used within DjangoPushProvider');
102
+ }
103
+
104
+ return context;
105
+ }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Simplified PWA Install Context
5
+ *
6
+ * Minimal global state for PWA installation
7
+ * No tracking, no metrics, no engagement — just install state
8
+ */
9
+
10
+ import React, { createContext, useContext, ReactNode } from 'react';
11
+
12
+ import { A2HSHint } from '../components/A2HSHint';
13
+ import type { InstallOutcome, PushNotificationOptions } from '../types';
14
+ import { useInstallPrompt } from '../hooks/useInstallPrompt';
15
+ import { PushProvider } from './PushContext';
16
+
17
+ /**
18
+ * Conditional PushProvider wrapper
19
+ * Only wraps children if push notifications are enabled
20
+ */
21
+ function ConditionalPushProvider({
22
+ enabled,
23
+ children,
24
+ ...config
25
+ }: PushNotificationOptions & { enabled: boolean; children: ReactNode }) {
26
+ if (!enabled) return <>{children}</>;
27
+ return <PushProvider {...config}>{children}</PushProvider>;
28
+ }
29
+
30
+ export interface PwaContextValue {
31
+ // Platform
32
+ isIOS: boolean;
33
+ isAndroid: boolean;
34
+ isSafari: boolean;
35
+ isChrome: boolean;
36
+
37
+ // State
38
+ isInstalled: boolean;
39
+ canPrompt: boolean;
40
+
41
+ // Actions
42
+ install: () => Promise<InstallOutcome>;
43
+ }
44
+
45
+ const PwaContext = createContext<PwaContextValue | undefined>(undefined);
46
+
47
+ export interface PwaConfig {
48
+ enabled?: boolean;
49
+ showInstallHint?: boolean;
50
+ resetAfterDays?: number | null;
51
+ delayMs?: number;
52
+ logo?: string;
53
+ pushNotifications?: PushNotificationOptions & {
54
+ delayMs?: number;
55
+ resetAfterDays?: number;
56
+ };
57
+ }
58
+
59
+ export function PwaProvider({ children, ...config }: PwaConfig & { children: React.ReactNode }) {
60
+ // If not enabled, acts as a simple pass-through
61
+ if (config.enabled === false) {
62
+ return <>{children}</>;
63
+ }
64
+
65
+ const prompt = useInstallPrompt();
66
+
67
+ const value: PwaContextValue = {
68
+ isIOS: prompt.isIOS,
69
+ isAndroid: prompt.isAndroid,
70
+ isSafari: prompt.isSafari,
71
+ isChrome: prompt.isChrome,
72
+ isInstalled: prompt.isInstalled,
73
+ canPrompt: prompt.canPrompt,
74
+ install: prompt.promptInstall,
75
+ };
76
+
77
+ const showHint = config.showInstallHint !== false;
78
+
79
+ // ✅ Explicit composition tree (no magic wrapping)
80
+ // Structure:
81
+ // - PwaContext.Provider (PWA install state)
82
+ // - ConditionalPushProvider (optional push notifications)
83
+ // - children (user content)
84
+ // - A2HSHint (PWA install hint UI)
85
+ return (
86
+ <PwaContext.Provider value={value}>
87
+ <ConditionalPushProvider
88
+ enabled={!!config.pushNotifications}
89
+ vapidPublicKey={config.pushNotifications?.vapidPublicKey || ''}
90
+ subscribeEndpoint={config.pushNotifications?.subscribeEndpoint}
91
+ >
92
+ {children}
93
+ {showHint && (
94
+ <A2HSHint
95
+ resetAfterDays={config.resetAfterDays}
96
+ delayMs={config.delayMs}
97
+ logo={config.logo}
98
+ pushNotifications={config.pushNotifications}
99
+ />
100
+ )}
101
+ </ConditionalPushProvider>
102
+ </PwaContext.Provider>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Use install context
108
+ * Must be used within <PwaProvider>
109
+ */
110
+ export function useInstall(): PwaContextValue {
111
+ const context = useContext(PwaContext);
112
+
113
+ if (context === undefined) {
114
+ throw new Error('useInstall must be used within <PwaProvider>');
115
+ }
116
+
117
+ return context;
118
+ }