@djangocfg/ui-nextjs 2.1.227 → 2.1.228

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.
@@ -0,0 +1,235 @@
1
+ # PWA Install
2
+
3
+ Progressive Web App installation snippet for web applications.
4
+
5
+ ## Responsibility
6
+
7
+ **Installation of PWA on user's device** (Add to Home Screen)
8
+
9
+ - iOS Safari: Visual guide for manual installation
10
+ - Android Chrome: Native install prompt
11
+ - Unified UX across platforms
12
+ - Smart dismissal with auto-reset
13
+
14
+ ## Quick Start
15
+
16
+ ```tsx
17
+ // app/layout.tsx
18
+ import { PwaProvider, A2HSHint } from '@/snippets/PWAInstall';
19
+
20
+ export default function RootLayout({ children }) {
21
+ return (
22
+ <html>
23
+ <body>
24
+ <PwaProvider>
25
+ {children}
26
+ <A2HSHint resetAfterDays={3} />
27
+ </PwaProvider>
28
+ </body>
29
+ </html>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ## What You Get
35
+
36
+ - **Unified UX** → Same hint position (bottom) for both iOS & Android
37
+ - **iOS Safari** → Click hint → Opens visual step-by-step guide
38
+ - **Android Chrome** → Click hint → Native install prompt
39
+ - **Visual guide** → Adaptive (drawer on mobile, modal on desktop)
40
+ - **Zero config** → Works out of the box
41
+ - **Smart reset** → Re-appears after 3 days (user gets second chance)
42
+ - **No spam** → Dismissible, respects user choice
43
+
44
+ ## API
45
+
46
+ ### `<PwaProvider>`
47
+
48
+ Wrap your app once:
49
+
50
+ ```tsx
51
+ <PwaProvider>
52
+ {children}
53
+ </PwaProvider>
54
+ ```
55
+
56
+ ### `<A2HSHint />`
57
+
58
+ Unified install hint for iOS & Android (auto-shows, dismissible):
59
+
60
+ ```tsx
61
+ <A2HSHint
62
+ resetAfterDays={3} // Default: 3 days (set to null for never)
63
+ delayMs={3000} // Default: 3 seconds
64
+ forceShow={isDevelopment} // Show on ANY browser (for dev testing)
65
+ logo="/logo192.png" // App logo URL (fallback: Share icon)
66
+ />
67
+ ```
68
+
69
+ **Behavior (iOS Safari):**
70
+ - Shows after 3 seconds
71
+ - Text: "Keep terminal with you → Tap to learn how"
72
+ - Click → Opens visual guide (adaptive: drawer on mobile, modal on desktop)
73
+ - Dismissible (saved to localStorage)
74
+ - Auto-resets after 3 days
75
+
76
+ **Behavior (Android Chrome):**
77
+ - Shows after 3 seconds
78
+ - Text: "Install Cmdop → Tap to install"
79
+ - Click → Triggers native install prompt
80
+ - Dismissible (saved to localStorage)
81
+ - Auto-resets after 3 days
82
+
83
+ ### `useInstall()` hook
84
+
85
+ Access install state anywhere:
86
+
87
+ ```tsx
88
+ import { useInstall } from '@/snippets/PWAInstall';
89
+
90
+ function Header() {
91
+ const { isIOS, isInstalled, canPrompt, install } = useInstall();
92
+
93
+ return (
94
+ <header>
95
+ {canPrompt && <button onClick={install}>Install</button>}
96
+ {isInstalled && <span>✓ Installed</span>}
97
+ </header>
98
+ );
99
+ }
100
+ ```
101
+
102
+ **Returns:**
103
+ ```ts
104
+ {
105
+ isIOS: boolean;
106
+ isAndroid: boolean;
107
+ isSafari: boolean;
108
+ isChrome: boolean;
109
+ isInstalled: boolean;
110
+ canPrompt: boolean;
111
+ install: () => Promise<'accepted' | 'dismissed' | null>;
112
+ }
113
+ ```
114
+
115
+ ### `usePWAPageResume()` hook
116
+
117
+ Resume last page when opening PWA:
118
+
119
+ ```tsx
120
+ import { usePWAPageResume } from '@/snippets/PWAInstall';
121
+
122
+ function AppManager() {
123
+ const { isPWA } = usePWAPageResume({ enabled: true });
124
+
125
+ return null; // Just manages state
126
+ }
127
+ ```
128
+
129
+ **Behavior:**
130
+ - Saves current pathname on every navigation (always, not just in PWA mode)
131
+ - Restores last page on PWA launch (only once per session)
132
+ - Auto-excludes auth, error, and callback pages
133
+ - Uses TTL (24 hours) to auto-expire saved page
134
+
135
+ ### `<PWAPageResumeManager />`
136
+
137
+ Component wrapper for `usePWAPageResume`:
138
+
139
+ ```tsx
140
+ import { PWAPageResumeManager } from '@/snippets/PWAInstall';
141
+
142
+ // In your layout
143
+ <PWAPageResumeManager enabled={true} />
144
+ ```
145
+
146
+ ## Page Resume via BaseApp
147
+
148
+ The easiest way to enable page resume is through BaseApp config:
149
+
150
+ ```tsx
151
+ <BaseApp
152
+ pwaInstall={{
153
+ enabled: true,
154
+ resumeLastPage: true, // Enable page resume
155
+ logo: '/logo192.png',
156
+ }}
157
+ >
158
+ {children}
159
+ </BaseApp>
160
+ ```
161
+
162
+ ## Usage with Push Notifications
163
+
164
+ Use together with the PushNotifications snippet:
165
+
166
+ ```tsx
167
+ import { PwaProvider, A2HSHint } from '@/snippets/PWAInstall';
168
+ import { DjangoPushProvider, PushPrompt } from '@/snippets/PushNotifications';
169
+
170
+ export default function Layout({ children }) {
171
+ return (
172
+ <PwaProvider>
173
+ <DjangoPushProvider vapidPublicKey={VAPID_KEY}>
174
+ {children}
175
+
176
+ {/* PWA Install hint */}
177
+ <A2HSHint resetAfterDays={3} />
178
+
179
+ {/* Push notification prompt (after PWA install) */}
180
+ <PushPrompt
181
+ requirePWA={true}
182
+ delayMs={5000}
183
+ resetAfterDays={7}
184
+ />
185
+ </DjangoPushProvider>
186
+ </PwaProvider>
187
+ );
188
+ }
189
+ ```
190
+
191
+ ## Browser Support
192
+
193
+ | Platform | Browser | Support |
194
+ |----------|---------|---------|
195
+ | iOS | Safari | ✅ Visual guide |
196
+ | iOS | Chrome/Firefox | ✅ Visual guide (via Share menu) |
197
+ | Android | Chrome | ✅ Native prompt |
198
+ | Android | Firefox | ⚠️ Manual only |
199
+ | Desktop | Chrome/Edge | ✅ Native prompt |
200
+
201
+ ## Architecture
202
+
203
+ ```
204
+ PWAInstall/
205
+ ├── context/
206
+ │ └── InstallContext.tsx # Install state management
207
+ ├── components/
208
+ │ ├── A2HSHint.tsx # Unified install hint
209
+ │ ├── IOSGuide.tsx # Visual guide wrapper
210
+ │ ├── IOSGuideDrawer.tsx # Mobile guide
211
+ │ ├── IOSGuideModal.tsx # Desktop guide
212
+ │ └── PWAPageResumeManager.tsx # Page resume component
213
+ ├── hooks/
214
+ │ ├── useInstallPrompt.ts # Install prompt logic
215
+ │ ├── useIsPWA.ts # PWA detection
216
+ │ └── usePWAPageResume.ts # Page resume on PWA launch
217
+ ├── utils/
218
+ │ ├── platform.ts # Platform detection
219
+ │ ├── localStorage.ts # Persistence
220
+ │ └── logger.ts # Logging
221
+ └── types/
222
+ ├── platform.ts
223
+ ├── install.ts
224
+ ├── config.ts
225
+ └── components.ts
226
+ ```
227
+
228
+ ## Separation from Push Notifications
229
+
230
+ This snippet is **completely independent** from push notifications:
231
+
232
+ - **PWAInstall** → Handles device installation
233
+ - **PushNotifications** → Handles web push subscriptions
234
+
235
+ Both can be used together or separately.
@@ -0,0 +1,236 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * PWA Install Hint (Unified for iOS & Android)
5
+ *
6
+ * Inline, non-blocking hint that shows at the bottom of the screen
7
+ * - iOS Safari: Opens visual guide on click
8
+ * - Android Chrome: Triggers native install prompt on click
9
+ * - Unified UX: Same position, same style, same behavior
10
+ *
11
+ * Auto-resets after 3 days (configurable)
12
+ */
13
+
14
+ import { ChevronRight, Download, Share, X } from 'lucide-react';
15
+ import React, { useEffect, useMemo, useState } from 'react';
16
+
17
+ import { useAppT } from '@djangocfg/i18n';
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+
20
+ import { useInstall } from '../context/InstallContext';
21
+ import { isA2HSDismissedRecently, markA2HSDismissed } from '../utils/localStorage';
22
+ import { pwaLogger } from '../utils/logger';
23
+ import { DesktopGuide } from './DesktopGuide';
24
+ import { IOSGuide } from './IOSGuide';
25
+
26
+ const DEFAULT_RESET_DAYS = 3;
27
+
28
+ interface A2HSHintProps {
29
+ /**
30
+ * Additional class names for the container
31
+ */
32
+ className?: string;
33
+
34
+ /**
35
+ * Number of days before re-showing dismissed hint
36
+ * @default 3
37
+ * Set to null to never reset (show once forever)
38
+ */
39
+ resetAfterDays?: number | null;
40
+
41
+ /**
42
+ * Delay before showing hint (ms)
43
+ * @default 3000
44
+ */
45
+ delayMs?: number;
46
+
47
+ /**
48
+ * Demo mode - shows hint on all platforms with appropriate guides
49
+ * Production: only iOS Safari & Android Chrome
50
+ * Demo: shows on desktop too with desktop install guide
51
+ * @default false
52
+ */
53
+ demo?: boolean;
54
+
55
+ /**
56
+ * App logo URL to display in hint
57
+ * If not provided, uses Share icon
58
+ */
59
+ logo?: string;
60
+ }
61
+
62
+ export function A2HSHint({
63
+ className,
64
+ resetAfterDays = DEFAULT_RESET_DAYS,
65
+ delayMs = 3000,
66
+ demo = false,
67
+ logo,
68
+ }: A2HSHintProps = {}) {
69
+ const { isIOS, isDesktop, isInstalled, canPrompt, install } = useInstall();
70
+ const [show, setShow] = useState(false);
71
+ const [showGuide, setShowGuide] = useState(false);
72
+ const [installing, setInstalling] = useState(false);
73
+ const t = useAppT();
74
+
75
+ const labels = useMemo(() => ({
76
+ addToHomeScreen: t('layouts.pwa.addToHomeScreen'),
77
+ installApp: t('layouts.pwa.installApp'),
78
+ tapToLearnHow: t('layouts.pwa.tapToLearnHow'),
79
+ clickToSeeGuide: t('layouts.pwa.clickToSeeGuide'),
80
+ tapToInstall: t('layouts.pwa.tapToInstall'),
81
+ installing: t('layouts.pwa.installing'),
82
+ dismiss: t('layouts.pwa.dismiss'),
83
+ appLogo: t('layouts.pwa.appLogo'),
84
+ }), [t]);
85
+
86
+ // Determine if should show hint
87
+ // Production: iOS (all browsers support PWA via Share) & Android Chrome with native prompt
88
+ // Demo: show on all platforms (desktop, iOS, Android)
89
+ const shouldShow = demo
90
+ ? !isInstalled // Demo: show on all platforms if not installed
91
+ : !isInstalled && (isIOS || canPrompt); // Production: iOS (all browsers) or Android with prompt
92
+
93
+ useEffect(() => {
94
+ if (!shouldShow) return;
95
+
96
+ // Check if previously dismissed (skip localStorage check in demo mode)
97
+ if (!demo && typeof window !== 'undefined') {
98
+ // If resetAfterDays is null, never reset (check with very large number)
99
+ if (resetAfterDays === null) {
100
+ if (isA2HSDismissedRecently(Number.MAX_SAFE_INTEGER)) {
101
+ return; // Dismissed forever
102
+ }
103
+ } else if (isA2HSDismissedRecently(resetAfterDays)) {
104
+ return; // Still within reset period
105
+ }
106
+ }
107
+
108
+ // Show after delay (user is already engaged)
109
+ const timer = setTimeout(() => setShow(true), delayMs);
110
+ return () => clearTimeout(timer);
111
+ }, [shouldShow, resetAfterDays, delayMs, demo]);
112
+
113
+ const handleDismiss = () => {
114
+ setShow(false);
115
+ // Don't save to localStorage if demo mode (dev testing)
116
+ if (!demo) {
117
+ markA2HSDismissed();
118
+ }
119
+ };
120
+
121
+ const handleGuideDismiss = () => {
122
+ setShowGuide(false);
123
+ // In demo mode, keep hint visible after guide is dismissed
124
+ // In production mode, dismiss both guide and hint
125
+ if (!demo) {
126
+ handleDismiss();
127
+ }
128
+ };
129
+
130
+ const handleClick = async () => {
131
+ // iOS (all browsers support PWA via Share menu)
132
+ // iOS or Desktop: Open visual guide
133
+ if (isIOS || isDesktop) {
134
+ setShowGuide(true);
135
+ } else if (canPrompt) {
136
+ // Android: Trigger native install prompt
137
+ setInstalling(true);
138
+ try {
139
+ await install();
140
+ // If install succeeds, dismiss hint
141
+ handleDismiss();
142
+ } catch (error) {
143
+ pwaLogger.error('[A2HSHint] Install error:', error);
144
+ } finally {
145
+ setInstalling(false);
146
+ }
147
+ }
148
+ };
149
+
150
+ if (!show) return null;
151
+
152
+ // Determine which guide/action to show
153
+ let title: string;
154
+ let subtitle: React.ReactNode;
155
+
156
+ if (isIOS) {
157
+ title = labels.addToHomeScreen;
158
+ subtitle = <>{labels.tapToLearnHow} <ChevronRight className="w-3 h-3" /></>;
159
+ } else if (isDesktop) {
160
+ title = labels.installApp;
161
+ subtitle = <>{labels.clickToSeeGuide} <ChevronRight className="w-3 h-3" /></>;
162
+ } else {
163
+ // Android or other mobile with native prompt
164
+ title = labels.installApp;
165
+ subtitle = <>{labels.tapToInstall} <Download className="w-3 h-3" /></>;
166
+ }
167
+
168
+ return (
169
+ <>
170
+ <div className={cn(
171
+ "fixed bottom-4 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 duration-300",
172
+ demo && "relative inset-auto z-auto", // Demo mode: remove fixed positioning
173
+ className
174
+ )}>
175
+ {/* Make entire card clickable */}
176
+ <div
177
+ role="button"
178
+ tabIndex={0}
179
+ onClick={handleClick}
180
+ onKeyDown={(e) => {
181
+ if (e.key === 'Enter' || e.key === ' ') {
182
+ e.preventDefault();
183
+ handleClick();
184
+ }
185
+ }}
186
+ className={cn(
187
+ "w-full bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-lg cursor-pointer hover:bg-zinc-800 transition-colors",
188
+ installing && "opacity-70 cursor-not-allowed"
189
+ )}
190
+ aria-disabled={installing}
191
+ >
192
+ <div className="flex items-center gap-3">
193
+ {/* App logo or icon */}
194
+ <div className="flex-shrink-0">
195
+ {logo ? (
196
+ <img src={logo} alt={labels.appLogo} className="w-10 h-10 rounded-lg" />
197
+ ) : (
198
+ <Share className="w-5 h-5 text-blue-400" />
199
+ )}
200
+ </div>
201
+
202
+ {/* Content - now just displays, clicking anywhere triggers action */}
203
+ <div className="flex-1 min-w-0">
204
+ <p className="text-sm font-medium text-white mb-1">{title}</p>
205
+ <p className="text-xs text-zinc-400 flex items-center gap-1">
206
+ {installing ? labels.installing : subtitle}
207
+ </p>
208
+ </div>
209
+
210
+ {/* Close button - prevent event bubbling */}
211
+ <button
212
+ onClick={(e) => {
213
+ e.stopPropagation();
214
+ handleDismiss();
215
+ }}
216
+ className="flex-shrink-0 p-1 hover:bg-zinc-700 rounded transition-colors"
217
+ aria-label={labels.dismiss}
218
+ >
219
+ <X className="w-4 h-4 text-zinc-400" />
220
+ </button>
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ {/* Show appropriate guide based on platform */}
226
+ {isIOS && (
227
+ <IOSGuide open={showGuide} onDismiss={handleGuideDismiss} />
228
+ )}
229
+
230
+ {/* Desktop guide */}
231
+ {isDesktop && (
232
+ <DesktopGuide open={showGuide} onDismiss={handleGuideDismiss} />
233
+ )}
234
+ </>
235
+ );
236
+ }
@@ -0,0 +1,234 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Desktop Installation Guide Modal
5
+ *
6
+ * Visual step-by-step guide for installing PWA on desktop browsers
7
+ * Uses platform detection from InstallContext
8
+ * Supports: Chrome, Edge, Brave, Arc, Vivaldi, Opera, Yandex, Firefox, Safari
9
+ */
10
+
11
+ import { ArrowDownToLine, Check, Menu, Monitor, Plus, Search } from 'lucide-react';
12
+ import React, { useMemo } from 'react';
13
+
14
+ import { useAppT } from '@djangocfg/i18n';
15
+ import {
16
+ Button, Card, CardContent, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
17
+ DialogTitle
18
+ } from '@djangocfg/ui-core/components';
19
+
20
+ import { useInstall } from '../context/InstallContext';
21
+
22
+ import type { IOSGuideModalProps, InstallStep } from '../types';
23
+ type BrowserCategory = 'chromium' | 'firefox' | 'safari' | 'unknown';
24
+
25
+ function getBrowserCategory(browser: {
26
+ isChromium: boolean;
27
+ isFirefox: boolean;
28
+ isSafari: boolean;
29
+ }): BrowserCategory {
30
+ if (browser.isChromium) return 'chromium';
31
+ if (browser.isFirefox) return 'firefox';
32
+ if (browser.isSafari) return 'safari';
33
+ return 'unknown';
34
+ }
35
+
36
+ type TranslationFn = (key: any) => string;
37
+
38
+ function getBrowserSteps(category: BrowserCategory, t: TranslationFn): InstallStep[] {
39
+ switch (category) {
40
+ case 'chromium':
41
+ return [
42
+ {
43
+ number: 1,
44
+ title: t('layouts.pwa.chromiumStep1Title'),
45
+ icon: ArrowDownToLine,
46
+ description: t('layouts.pwa.chromiumStep1Desc'),
47
+ },
48
+ {
49
+ number: 2,
50
+ title: t('layouts.pwa.chromiumStep2Title'),
51
+ icon: Plus,
52
+ description: t('layouts.pwa.chromiumStep2Desc'),
53
+ },
54
+ {
55
+ number: 3,
56
+ title: t('layouts.pwa.chromiumStep3Title'),
57
+ icon: Check,
58
+ description: t('layouts.pwa.chromiumStep3Desc'),
59
+ },
60
+ ];
61
+
62
+ case 'firefox':
63
+ return [
64
+ {
65
+ number: 1,
66
+ title: t('layouts.pwa.firefoxStep1Title'),
67
+ icon: Menu,
68
+ description: t('layouts.pwa.firefoxStep1Desc'),
69
+ },
70
+ {
71
+ number: 2,
72
+ title: t('layouts.pwa.firefoxStep2Title'),
73
+ icon: Search,
74
+ description: t('layouts.pwa.firefoxStep2Desc'),
75
+ },
76
+ {
77
+ number: 3,
78
+ title: t('layouts.pwa.firefoxStep3Title'),
79
+ icon: Check,
80
+ description: t('layouts.pwa.firefoxStep3Desc'),
81
+ },
82
+ ];
83
+
84
+ case 'safari':
85
+ return [
86
+ {
87
+ number: 1,
88
+ title: t('layouts.pwa.safariStep1Title'),
89
+ icon: Monitor,
90
+ description: t('layouts.pwa.safariStep1Desc'),
91
+ },
92
+ {
93
+ number: 2,
94
+ title: t('layouts.pwa.safariStep2Title'),
95
+ icon: ArrowDownToLine,
96
+ description: t('layouts.pwa.safariStep2Desc'),
97
+ },
98
+ ];
99
+
100
+ default:
101
+ return [
102
+ {
103
+ number: 1,
104
+ title: t('layouts.pwa.unknownStep1Title'),
105
+ icon: ArrowDownToLine,
106
+ description: t('layouts.pwa.unknownStep1Desc'),
107
+ },
108
+ {
109
+ number: 2,
110
+ title: t('layouts.pwa.unknownStep2Title'),
111
+ icon: Menu,
112
+ description: t('layouts.pwa.unknownStep2Desc'),
113
+ },
114
+ {
115
+ number: 3,
116
+ title: t('layouts.pwa.unknownStep3Title'),
117
+ icon: Check,
118
+ description: t('layouts.pwa.unknownStep3Desc'),
119
+ },
120
+ ];
121
+ }
122
+ }
123
+
124
+ function StepCard({ step }: { step: InstallStep }) {
125
+ return (
126
+ <Card className="border border-border">
127
+ <CardContent className="p-4">
128
+ <div className="flex items-start gap-3">
129
+ <div
130
+ className="flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0"
131
+ style={{ width: '32px', height: '32px' }}
132
+ >
133
+ <span className="text-sm font-semibold">{step.number}</span>
134
+ </div>
135
+
136
+ <div className="flex-1 min-w-0">
137
+ <div className="flex items-center gap-2 mb-1">
138
+ <step.icon className="w-5 h-5 text-primary" />
139
+ <h3 className="font-semibold text-foreground">{step.title}</h3>
140
+ </div>
141
+ <p className="text-sm text-muted-foreground">{step.description}</p>
142
+ </div>
143
+ </div>
144
+ </CardContent>
145
+ </Card>
146
+ );
147
+ }
148
+
149
+ export function DesktopGuide({ onDismiss, open = true }: IOSGuideModalProps) {
150
+ const {
151
+ browserName,
152
+ isChromium,
153
+ isFirefox,
154
+ isSafari,
155
+ isEdge,
156
+ isBrave,
157
+ isArc,
158
+ isVivaldi,
159
+ isOpera,
160
+ isYandex,
161
+ } = useInstall();
162
+ const t = useAppT();
163
+
164
+ const labels = useMemo(() => ({
165
+ title: t('layouts.pwa.desktopTitle'),
166
+ description: t('layouts.pwa.desktopDescription'),
167
+ descSafari: t('layouts.pwa.desktopDescSafari'),
168
+ tip: t('layouts.pwa.desktopTip'),
169
+ firefoxNote: t('layouts.pwa.desktopFirefoxNote'),
170
+ gotIt: t('layouts.pwa.gotIt'),
171
+ }), [t]);
172
+
173
+ const category = useMemo(
174
+ () => getBrowserCategory({ isChromium, isFirefox, isSafari }),
175
+ [isChromium, isFirefox, isSafari]
176
+ );
177
+
178
+ const steps = useMemo(() => getBrowserSteps(category, t), [category, t]);
179
+
180
+ // Get specific browser display name
181
+ const displayName = useMemo(() => {
182
+ if (isEdge) return 'Edge';
183
+ if (isBrave) return 'Brave';
184
+ if (isArc) return 'Arc';
185
+ if (isVivaldi) return 'Vivaldi';
186
+ if (isOpera) return 'Opera';
187
+ if (isYandex) return 'Yandex Browser';
188
+ return browserName;
189
+ }, [browserName, isEdge, isBrave, isArc, isVivaldi, isOpera, isYandex]);
190
+
191
+ return (
192
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
193
+ <DialogContent className="sm:max-w-md">
194
+ <DialogHeader className="text-left">
195
+ <DialogTitle className="flex items-center gap-2">
196
+ <Monitor className="w-5 h-5 text-primary" />
197
+ {labels.title}
198
+ </DialogTitle>
199
+ <DialogDescription className="text-left">
200
+ {isSafari
201
+ ? labels.descSafari
202
+ : labels.description.replace('{browser}', displayName)
203
+ }
204
+ </DialogDescription>
205
+ </DialogHeader>
206
+
207
+ <div className="space-y-3 py-4">
208
+ {steps.map((step) => (
209
+ <StepCard key={step.number} step={step} />
210
+ ))}
211
+ </div>
212
+
213
+ {category === 'chromium' && (
214
+ <div className="p-3 bg-muted/30 rounded-lg text-xs text-muted-foreground">
215
+ 💡 {labels.tip.replace('{browser}', displayName)}
216
+ </div>
217
+ )}
218
+
219
+ {category === 'firefox' && (
220
+ <div className="p-3 bg-amber-500/10 rounded-lg text-xs text-amber-700 dark:text-amber-400">
221
+ ℹ️ {labels.firefoxNote}
222
+ </div>
223
+ )}
224
+
225
+ <DialogFooter>
226
+ <Button onClick={onDismiss} variant="default" className="w-full">
227
+ <Check className="w-4 h-4 mr-2" />
228
+ {labels.gotIt}
229
+ </Button>
230
+ </DialogFooter>
231
+ </DialogContent>
232
+ </Dialog>
233
+ );
234
+ }