create-varity-app 2.0.0-beta.1

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/create.js +141 -0
  4. package/dist/index.js +45 -0
  5. package/dist/utils.js +29 -0
  6. package/package.json +61 -0
  7. package/template/.env.example +17 -0
  8. package/template/KNOWN_ISSUES.md +69 -0
  9. package/template/LICENSE +21 -0
  10. package/template/README.md +241 -0
  11. package/template/gitignore +42 -0
  12. package/template/next-env.d.ts +6 -0
  13. package/template/next.config.js +21 -0
  14. package/template/package.json +39 -0
  15. package/template/postcss.config.js +6 -0
  16. package/template/public/logo.svg +4 -0
  17. package/template/public/robots.txt +4 -0
  18. package/template/public/sitemap.xml +4 -0
  19. package/template/src/app/dashboard/layout.tsx +298 -0
  20. package/template/src/app/dashboard/page.tsx +209 -0
  21. package/template/src/app/dashboard/projects/page.tsx +638 -0
  22. package/template/src/app/dashboard/settings/page.tsx +749 -0
  23. package/template/src/app/dashboard/tasks/page.tsx +301 -0
  24. package/template/src/app/dashboard/team/page.tsx +295 -0
  25. package/template/src/app/globals.css +177 -0
  26. package/template/src/app/icon.svg +4 -0
  27. package/template/src/app/layout.tsx +33 -0
  28. package/template/src/app/login/page.tsx +98 -0
  29. package/template/src/app/not-found.tsx +20 -0
  30. package/template/src/app/page.tsx +23 -0
  31. package/template/src/components/dashboard/DashboardStats.tsx +137 -0
  32. package/template/src/components/dashboard/RecentActivity.tsx +63 -0
  33. package/template/src/components/landing/CTA.tsx +42 -0
  34. package/template/src/components/landing/Features.tsx +116 -0
  35. package/template/src/components/landing/Hero.tsx +146 -0
  36. package/template/src/components/landing/HowItWorks.tsx +80 -0
  37. package/template/src/components/landing/Pricing.tsx +124 -0
  38. package/template/src/components/landing/Testimonials.tsx +78 -0
  39. package/template/src/components/providers.tsx +11 -0
  40. package/template/src/components/shared/Footer.tsx +71 -0
  41. package/template/src/components/shared/Navbar.tsx +87 -0
  42. package/template/src/lib/constants.ts +35 -0
  43. package/template/src/lib/database.ts +7 -0
  44. package/template/src/lib/hooks.ts +331 -0
  45. package/template/src/lib/utils.ts +68 -0
  46. package/template/src/lib/varity.ts +1 -0
  47. package/template/src/services/dashboardService.ts +589 -0
  48. package/template/src/types/index.ts +52 -0
  49. package/template/tailwind.config.js +27 -0
  50. package/template/tsconfig.json +23 -0
  51. package/template/varity.config.json +14 -0
@@ -0,0 +1,749 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useCurrentUser, useUserSettings } from '@/lib/hooks';
5
+ import type { UserSettings } from '@/types';
6
+ import {
7
+ Button,
8
+ Input,
9
+ Select,
10
+ Dialog,
11
+ ConfirmDialog,
12
+ useToast,
13
+ Toggle,
14
+ Avatar,
15
+ ProgressBar,
16
+ RadioGroup,
17
+ Checkbox,
18
+ Badge,
19
+ DataTable,
20
+ Skeleton,
21
+ } from '@varity-labs/ui-kit';
22
+ import { APP_NAME } from '@/lib/constants';
23
+ import {
24
+ User,
25
+ Copy,
26
+ Camera,
27
+ Shield,
28
+ CreditCard,
29
+ Database,
30
+ Download,
31
+ Lock,
32
+ Globe,
33
+ Bell,
34
+ Trash2,
35
+ LogOut,
36
+ Smartphone,
37
+ Key,
38
+ } from 'lucide-react';
39
+
40
+ const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID;
41
+
42
+ let PrivyUserProfile: any = null;
43
+ let usePrivyHook: any = null;
44
+
45
+ try {
46
+ const uiKit = require('@varity-labs/ui-kit');
47
+ PrivyUserProfile = uiKit.PrivyUserProfile;
48
+ usePrivyHook = uiKit.usePrivy;
49
+ } catch {}
50
+
51
+ const TABS = [
52
+ { id: 'general', label: 'General', icon: User },
53
+ { id: 'security', label: 'Security', icon: Shield },
54
+ { id: 'billing', label: 'Billing', icon: CreditCard },
55
+ { id: 'account', label: 'Account', icon: Database },
56
+ ] as const;
57
+
58
+ type TabId = (typeof TABS)[number]['id'];
59
+
60
+ // Mock usage data (developers replace with their billing provider)
61
+ const MOCK_USAGE = {
62
+ projects: { used: 5, limit: 10 },
63
+ storage: { used: 2.3, limit: 5 },
64
+ bandwidth: { used: 45, limit: 100 },
65
+ };
66
+
67
+ function SettingsSkeleton() {
68
+ return (
69
+ <div className="space-y-4">
70
+ <Skeleton width="100%" height={40} />
71
+ <Skeleton width="100%" height={40} />
72
+ <Skeleton width="60%" height={40} />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ export default function SettingsPage() {
78
+ const { email, name, id } = useCurrentUser();
79
+ const { settings, loading, update: updateSettings } = useUserSettings();
80
+ const toast = useToast();
81
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on require() + env var, stable across renders
82
+ const privy = privyAppId && usePrivyHook ? usePrivyHook() : { logout: async () => {} };
83
+
84
+ const [activeTab, setActiveTab] = useState<TabId>('general');
85
+
86
+ // Dialog state
87
+ const [editProfileOpen, setEditProfileOpen] = useState(false);
88
+ const [profileForm, setProfileForm] = useState({ name: '', email: '' });
89
+ const [changePasswordOpen, setChangePasswordOpen] = useState(false);
90
+ const [revokeSessionConfirmOpen, setRevokeSessionConfirmOpen] = useState(false);
91
+ const [sessionToRevoke, setSessionToRevoke] = useState<string | null>(null);
92
+ const [deleteAccountConfirmOpen, setDeleteAccountConfirmOpen] = useState(false);
93
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
94
+
95
+ // Active sessions (stateful so revoke actually removes from UI)
96
+ const [sessions, setSessions] = useState([
97
+ {
98
+ id: '1',
99
+ browser: 'Chrome on Windows',
100
+ location: 'San Francisco, US',
101
+ ip: '192.168.1.1',
102
+ lastActive: '2026-02-12T12:00:00.000Z',
103
+ current: true,
104
+ },
105
+ {
106
+ id: '2',
107
+ browser: 'Safari on macOS',
108
+ location: 'New York, US',
109
+ ip: '192.168.1.2',
110
+ lastActive: '2026-02-12T11:00:00.000Z',
111
+ current: false,
112
+ },
113
+ ]);
114
+
115
+ // Sync profile form when Privy data arrives asynchronously
116
+ useEffect(() => {
117
+ if (name || email) {
118
+ setProfileForm({ name: name || '', email: email || '' });
119
+ }
120
+ }, [name, email]);
121
+
122
+ const handleCopyId = () => {
123
+ navigator.clipboard.writeText(id);
124
+ toast.success('User ID copied to clipboard');
125
+ };
126
+
127
+ const handleSaveProfile = () => {
128
+ toast.info('Profile changes are managed by your auth provider');
129
+ setEditProfileOpen(false);
130
+ };
131
+
132
+ const handleChangePassword = () => {
133
+ toast.info('Password changes are managed by your auth provider');
134
+ setChangePasswordOpen(false);
135
+ };
136
+
137
+ const handleRevokeSession = () => {
138
+ if (sessionToRevoke) {
139
+ setSessions((prev) => prev.filter((s) => s.id !== sessionToRevoke));
140
+ toast.success('Session revoked successfully');
141
+ }
142
+ setRevokeSessionConfirmOpen(false);
143
+ setSessionToRevoke(null);
144
+ };
145
+
146
+ const handleDownloadData = () => {
147
+ const exportData = {
148
+ user: { id, name, email },
149
+ settings: settings,
150
+ exportedAt: new Date().toISOString(),
151
+ };
152
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
153
+ const url = URL.createObjectURL(blob);
154
+ const a = document.createElement('a');
155
+ a.href = url;
156
+ a.download = `${APP_NAME.toLowerCase()}-data-export.json`;
157
+ a.click();
158
+ URL.revokeObjectURL(url);
159
+ toast.success('Data exported successfully');
160
+ };
161
+
162
+ const handleDeleteAccount = () => {
163
+ toast.error('Account deletion is not available in the demo');
164
+ setDeleteAccountConfirmOpen(false);
165
+ setDeleteConfirmText('');
166
+ };
167
+
168
+ const handleSettingChange = (updates: Partial<UserSettings>) => {
169
+ updateSettings(updates).catch(() => {
170
+ toast.error('Failed to save setting');
171
+ });
172
+ };
173
+
174
+ const sessionColumns = [
175
+ {
176
+ key: 'browser',
177
+ header: 'Device',
178
+ render: (value: string, row: any) => (
179
+ <div className="flex items-center gap-2">
180
+ <Smartphone className="h-4 w-4 text-gray-400" />
181
+ <div>
182
+ <div className="font-medium text-gray-900">
183
+ {value}
184
+ {row.current && (
185
+ <span className="ml-2 text-xs text-green-600 font-normal">(this device)</span>
186
+ )}
187
+ </div>
188
+ <div className="text-xs text-gray-500">{row.ip}</div>
189
+ </div>
190
+ </div>
191
+ ),
192
+ },
193
+ { key: 'location', header: 'Location' },
194
+ {
195
+ key: 'lastActive',
196
+ header: 'Last Active',
197
+ render: (value: string) => {
198
+ const date = new Date(value);
199
+ const now = new Date();
200
+ const diff = now.getTime() - date.getTime();
201
+ const hours = Math.floor(diff / 3600000);
202
+ if (hours === 0) return 'Just now';
203
+ if (hours < 24) return `${hours}h ago`;
204
+ return `${Math.floor(hours / 24)}d ago`;
205
+ },
206
+ },
207
+ {
208
+ key: 'id',
209
+ header: '',
210
+ render: (value: string, row: any) => (
211
+ row.current ? (
212
+ <Badge variant="green">Current</Badge>
213
+ ) : (
214
+ <Button
215
+ size="sm"
216
+ variant="outline"
217
+ onClick={() => {
218
+ setSessionToRevoke(value);
219
+ setRevokeSessionConfirmOpen(true);
220
+ }}
221
+ >
222
+ Revoke
223
+ </Button>
224
+ )
225
+ ),
226
+ },
227
+ ];
228
+
229
+ // --- Tab content renderers ---
230
+
231
+ const renderGeneral = () => (
232
+ <div className="space-y-6">
233
+ {/* Profile card */}
234
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
235
+ <div className="flex items-start justify-between mb-6">
236
+ <div className="flex items-center gap-4">
237
+ <div className="relative">
238
+ <Avatar name={name || 'User'} size="lg" />
239
+ <button
240
+ className="absolute bottom-0 right-0 p-1.5 bg-primary-600 rounded-full text-white hover:bg-primary-700 transition-colors"
241
+ onClick={() => toast.info('Profile picture upload coming soon')}
242
+ >
243
+ <Camera className="h-3 w-3" />
244
+ </button>
245
+ </div>
246
+ <div>
247
+ <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
248
+ {name || 'User'}
249
+ <Badge variant="blue">Free Plan</Badge>
250
+ </h2>
251
+ <p className="text-sm text-gray-600">{email || 'No email set'}</p>
252
+ </div>
253
+ </div>
254
+ <Button variant="outline" onClick={() => setEditProfileOpen(true)}>
255
+ <User className="h-4 w-4" />
256
+ Edit Profile
257
+ </Button>
258
+ </div>
259
+
260
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-100">
261
+ <div>
262
+ <label className="block text-xs font-medium text-gray-500 mb-1">User ID</label>
263
+ <div className="flex items-center gap-2">
264
+ <code className="text-sm text-gray-900 font-mono bg-gray-50 px-2 py-1 rounded">
265
+ {id.substring(0, 16)}...
266
+ </code>
267
+ <Button size="sm" variant="ghost" onClick={handleCopyId}>
268
+ <Copy className="h-3 w-3" />
269
+ </Button>
270
+ </div>
271
+ </div>
272
+ <div>
273
+ <label className="block text-xs font-medium text-gray-500 mb-1">Member Since</label>
274
+ <p className="text-sm text-gray-900">January 2026</p>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ {/* Preferences */}
280
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
281
+ <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
282
+ <Globe className="h-5 w-5" />
283
+ Preferences
284
+ </h2>
285
+
286
+ {loading || !settings ? (
287
+ <SettingsSkeleton />
288
+ ) : (
289
+ <div className="space-y-6">
290
+ <div>
291
+ <label className="block text-sm font-medium text-gray-900 mb-2">Theme</label>
292
+ <Select
293
+ value={settings.theme}
294
+ onChange={(e) => handleSettingChange({ theme: e.target.value as UserSettings['theme'] })}
295
+ options={[
296
+ { value: 'light', label: 'Light' },
297
+ { value: 'dark', label: 'Dark' },
298
+ { value: 'system', label: 'System' },
299
+ ]}
300
+ />
301
+ </div>
302
+
303
+ <div className="border-t border-gray-100 pt-4">
304
+ <h3 className="text-sm font-medium text-gray-900 flex items-center gap-2 mb-4">
305
+ <Bell className="h-4 w-4" />
306
+ Notifications
307
+ </h3>
308
+ <div className="space-y-4">
309
+ <Toggle
310
+ checked={settings.email_notifications}
311
+ onChange={(v) => handleSettingChange({ email_notifications: v })}
312
+ label="Email notifications"
313
+ description="Receive email notifications about your activity"
314
+ />
315
+ <Toggle
316
+ checked={settings.marketing_emails}
317
+ onChange={(v) => handleSettingChange({ marketing_emails: v })}
318
+ label="Marketing emails"
319
+ description="Receive emails about new features and updates"
320
+ />
321
+ <Toggle
322
+ checked={settings.product_updates}
323
+ onChange={(v) => handleSettingChange({ product_updates: v })}
324
+ label="Product updates"
325
+ description="Get notified when we ship new features"
326
+ />
327
+ </div>
328
+ </div>
329
+
330
+ <div className="border-t border-gray-100 pt-4">
331
+ <h3 className="text-sm font-medium text-gray-900 mb-4">Locale</h3>
332
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
333
+ <div>
334
+ <label className="block text-sm font-medium text-gray-700 mb-2">Date Format</label>
335
+ <Select
336
+ value={settings.date_format}
337
+ onChange={(e) => handleSettingChange({ date_format: e.target.value })}
338
+ options={[
339
+ { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
340
+ { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
341
+ { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
342
+ ]}
343
+ />
344
+ </div>
345
+ <div>
346
+ <label className="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
347
+ <Select
348
+ value={settings.timezone}
349
+ onChange={(e) => handleSettingChange({ timezone: e.target.value })}
350
+ options={[
351
+ { value: 'America/Los_Angeles', label: 'Pacific Time' },
352
+ { value: 'America/New_York', label: 'Eastern Time' },
353
+ { value: 'Europe/London', label: 'London' },
354
+ { value: 'Asia/Tokyo', label: 'Tokyo' },
355
+ ]}
356
+ />
357
+ </div>
358
+ <div>
359
+ <label className="block text-sm font-medium text-gray-700 mb-2">Language</label>
360
+ <Select
361
+ value={settings.language}
362
+ onChange={(e) => handleSettingChange({ language: e.target.value })}
363
+ options={[
364
+ { value: 'en', label: 'English' },
365
+ { value: 'es', label: 'Español' },
366
+ { value: 'fr', label: 'Français' },
367
+ ]}
368
+ />
369
+ </div>
370
+ </div>
371
+ </div>
372
+
373
+ <div className="border-t border-gray-100 pt-4">
374
+ <RadioGroup
375
+ value={settings.dashboard_layout}
376
+ onChange={(v) => handleSettingChange({ dashboard_layout: v as UserSettings['dashboard_layout'] })}
377
+ name="dashboard-layout"
378
+ label="Dashboard Layout"
379
+ options={[
380
+ { value: 'comfortable', label: 'Comfortable', description: 'More spacing and larger elements' },
381
+ { value: 'compact', label: 'Compact', description: 'Tighter spacing, fit more content' },
382
+ ]}
383
+ orientation="horizontal"
384
+ />
385
+ </div>
386
+ </div>
387
+ )}
388
+ </div>
389
+ </div>
390
+ );
391
+
392
+ const renderSecurity = () => (
393
+ <div className="space-y-6">
394
+ {/* 2FA */}
395
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
396
+ <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
397
+ <Shield className="h-5 w-5" />
398
+ Two-Factor Authentication
399
+ </h2>
400
+
401
+ {loading || !settings ? (
402
+ <SettingsSkeleton />
403
+ ) : (
404
+ <div className="space-y-4">
405
+ <div>
406
+ <Toggle
407
+ checked={settings.two_factor_enabled}
408
+ onChange={(v) => handleSettingChange({ two_factor_enabled: v })}
409
+ label={<span className="inline-flex items-center gap-2">Enable 2FA{settings.two_factor_enabled && <Badge variant="green">Enabled</Badge>}</span>}
410
+ description="Add an extra layer of security to your account"
411
+ />
412
+ </div>
413
+ {settings.two_factor_enabled && (
414
+ <p className="text-sm text-gray-500 pl-14">
415
+ Two-factor authentication is active. You will be prompted for a verification code when signing in.
416
+ </p>
417
+ )}
418
+ </div>
419
+ )}
420
+ </div>
421
+
422
+ {/* Password */}
423
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
424
+ <h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
425
+ <Lock className="h-5 w-5" />
426
+ Password
427
+ </h2>
428
+ <p className="text-sm text-gray-500 mb-4">
429
+ Password management is handled by your authentication provider.
430
+ </p>
431
+ <Button variant="outline" onClick={() => setChangePasswordOpen(true)}>
432
+ <Lock className="h-4 w-4" />
433
+ Change Password
434
+ </Button>
435
+ </div>
436
+
437
+ {/* Active Sessions */}
438
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
439
+ <div className="flex items-center justify-between mb-4">
440
+ <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
441
+ <Smartphone className="h-5 w-5" />
442
+ Active Sessions
443
+ </h2>
444
+ <span className="text-sm text-gray-500">{sessions.length} active</span>
445
+ </div>
446
+ <DataTable
447
+ columns={sessionColumns}
448
+ data={sessions}
449
+ emptyMessage="No active sessions"
450
+ />
451
+ </div>
452
+ </div>
453
+ );
454
+
455
+ const renderBilling = () => (
456
+ <div className="space-y-6">
457
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
458
+ <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
459
+ <CreditCard className="h-5 w-5" />
460
+ Current Plan
461
+ </h2>
462
+
463
+ <div className="p-4 bg-gray-50 rounded-lg">
464
+ <div className="flex items-center justify-between mb-2">
465
+ <span className="text-sm font-medium text-gray-700">Free Plan</span>
466
+ <Badge variant="blue">Active</Badge>
467
+ </div>
468
+ <p className="text-2xl font-bold text-gray-900">$0<span className="text-sm font-normal text-gray-600">/month</span></p>
469
+ <Button className="mt-4 w-full" variant="primary">
470
+ Upgrade to Pro
471
+ </Button>
472
+ </div>
473
+ </div>
474
+
475
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
476
+ <h2 className="text-lg font-semibold text-gray-900 mb-4">Usage</h2>
477
+ <div className="space-y-5">
478
+ <div>
479
+ <div className="flex items-center justify-between mb-2">
480
+ <span className="text-sm font-medium text-gray-700">Projects</span>
481
+ <span className="text-sm text-gray-600">{MOCK_USAGE.projects.used} / {MOCK_USAGE.projects.limit}</span>
482
+ </div>
483
+ <ProgressBar
484
+ value={(MOCK_USAGE.projects.used / MOCK_USAGE.projects.limit) * 100}
485
+ variant="primary"
486
+ />
487
+ </div>
488
+
489
+ <div>
490
+ <div className="flex items-center justify-between mb-2">
491
+ <span className="text-sm font-medium text-gray-700">Storage</span>
492
+ <span className="text-sm text-gray-600">{MOCK_USAGE.storage.used} GB / {MOCK_USAGE.storage.limit} GB</span>
493
+ </div>
494
+ <ProgressBar
495
+ value={(MOCK_USAGE.storage.used / MOCK_USAGE.storage.limit) * 100}
496
+ variant="success"
497
+ />
498
+ </div>
499
+
500
+ <div>
501
+ <div className="flex items-center justify-between mb-2">
502
+ <span className="text-sm font-medium text-gray-700">Bandwidth</span>
503
+ <span className="text-sm text-gray-600">{MOCK_USAGE.bandwidth.used} GB / {MOCK_USAGE.bandwidth.limit} GB</span>
504
+ </div>
505
+ <ProgressBar
506
+ value={(MOCK_USAGE.bandwidth.used / MOCK_USAGE.bandwidth.limit) * 100}
507
+ variant="warning"
508
+ />
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ );
514
+
515
+ const renderAccount = () => (
516
+ <div className="space-y-6">
517
+ {/* Data Export */}
518
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
519
+ <h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
520
+ <Download className="h-5 w-5" />
521
+ Export Data
522
+ </h2>
523
+ <p className="text-sm text-gray-500 mb-4">
524
+ Download a copy of all your data including profile, settings, and activity in JSON format.
525
+ </p>
526
+ <Button variant="outline" onClick={handleDownloadData}>
527
+ <Download className="h-4 w-4" />
528
+ Download My Data
529
+ </Button>
530
+ </div>
531
+
532
+ {/* Privacy Preferences */}
533
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
534
+ <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
535
+ <Database className="h-5 w-5" />
536
+ Privacy Preferences
537
+ </h2>
538
+
539
+ {loading || !settings ? (
540
+ <SettingsSkeleton />
541
+ ) : (
542
+ <div className="space-y-4">
543
+ <Checkbox
544
+ checked={settings.analytics_enabled}
545
+ onChange={(v) => handleSettingChange({ analytics_enabled: v })}
546
+ label="Usage analytics"
547
+ description="Help us improve by sharing anonymous usage data"
548
+ />
549
+ <Checkbox
550
+ checked={settings.cookies_enabled}
551
+ onChange={(v) => handleSettingChange({ cookies_enabled: v })}
552
+ label="Optional cookies"
553
+ description="Allow non-essential cookies for personalization"
554
+ />
555
+ </div>
556
+ )}
557
+ </div>
558
+
559
+ {/* Privy Account Settings */}
560
+ {privyAppId && PrivyUserProfile && (
561
+ <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
562
+ <h2 className="mb-4 text-lg font-semibold text-gray-900 flex items-center gap-2">
563
+ <Key className="h-5 w-5" />
564
+ Account Settings (Privy)
565
+ </h2>
566
+ <PrivyUserProfile showLogoutButton={false} />
567
+ </div>
568
+ )}
569
+
570
+ {/* Danger Zone */}
571
+ <div className="rounded-xl border border-red-200 bg-red-50 p-6">
572
+ <h2 className="mb-2 text-lg font-semibold text-red-900 flex items-center gap-2">
573
+ <Trash2 className="h-5 w-5" />
574
+ Danger Zone
575
+ </h2>
576
+ <p className="mb-4 text-sm text-red-700">
577
+ These actions are permanent and cannot be undone.
578
+ </p>
579
+ <div className="flex flex-wrap gap-3">
580
+ <Button variant="danger" onClick={() => privy.logout()}>
581
+ <LogOut className="h-4 w-4" />
582
+ Sign Out
583
+ </Button>
584
+ <Button variant="outline" onClick={() => setDeleteAccountConfirmOpen(true)}>
585
+ <Trash2 className="h-4 w-4" />
586
+ Delete Account
587
+ </Button>
588
+ </div>
589
+ </div>
590
+ </div>
591
+ );
592
+
593
+ const tabContent: Record<TabId, () => JSX.Element> = {
594
+ general: renderGeneral,
595
+ security: renderSecurity,
596
+ billing: renderBilling,
597
+ account: renderAccount,
598
+ };
599
+
600
+ return (
601
+ <div className="space-y-6">
602
+ {/* Header */}
603
+ <div>
604
+ <h1 className="text-2xl font-bold text-gray-900">Settings</h1>
605
+ <p className="mt-1 text-sm text-gray-600">
606
+ Manage your account, preferences, and security settings.
607
+ </p>
608
+ </div>
609
+
610
+ {/* Tab bar */}
611
+ <div className="border-b border-gray-200">
612
+ <nav className="flex gap-1 -mb-px" aria-label="Settings tabs">
613
+ {TABS.map((tab) => {
614
+ const Icon = tab.icon;
615
+ const isActive = activeTab === tab.id;
616
+ return (
617
+ <button
618
+ key={tab.id}
619
+ onClick={() => setActiveTab(tab.id)}
620
+ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
621
+ isActive
622
+ ? 'border-primary-600 text-primary-600'
623
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
624
+ }`}
625
+ >
626
+ <Icon className="h-4 w-4" />
627
+ {tab.label}
628
+ </button>
629
+ );
630
+ })}
631
+ </nav>
632
+ </div>
633
+
634
+ {/* Tab content */}
635
+ {tabContent[activeTab]()}
636
+
637
+ {/* Dialogs (always mounted, independent of tabs) */}
638
+ <Dialog
639
+ open={editProfileOpen}
640
+ onClose={() => setEditProfileOpen(false)}
641
+ title="Edit Profile"
642
+ >
643
+ <div className="space-y-4">
644
+ <p className="text-sm text-gray-500">
645
+ Your profile is managed by your authentication provider. Changes made here are for display purposes only.
646
+ </p>
647
+ <Input
648
+ label="Display Name"
649
+ value={profileForm.name}
650
+ onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
651
+ placeholder="Your name"
652
+ />
653
+ <Input
654
+ label="Email"
655
+ type="email"
656
+ value={profileForm.email}
657
+ onChange={(e) => setProfileForm({ ...profileForm, email: e.target.value })}
658
+ placeholder="your@email.com"
659
+ disabled
660
+ hint="Email is managed by your auth provider"
661
+ />
662
+ <div className="flex justify-end gap-3 pt-4">
663
+ <Button variant="outline" onClick={() => setEditProfileOpen(false)}>
664
+ Cancel
665
+ </Button>
666
+ <Button onClick={handleSaveProfile}>
667
+ Save Changes
668
+ </Button>
669
+ </div>
670
+ </div>
671
+ </Dialog>
672
+
673
+ <Dialog
674
+ open={changePasswordOpen}
675
+ onClose={() => setChangePasswordOpen(false)}
676
+ title="Change Password"
677
+ >
678
+ <div className="space-y-4">
679
+ <p className="text-sm text-gray-500">
680
+ Password management is handled by your authentication provider. Use the fields below to request a password change.
681
+ </p>
682
+ <Input
683
+ label="Current Password"
684
+ type="password"
685
+ placeholder="Enter current password"
686
+ />
687
+ <Input
688
+ label="New Password"
689
+ type="password"
690
+ placeholder="Enter new password"
691
+ />
692
+ <Input
693
+ label="Confirm New Password"
694
+ type="password"
695
+ placeholder="Confirm new password"
696
+ />
697
+ <div className="flex justify-end gap-3 pt-4">
698
+ <Button variant="outline" onClick={() => setChangePasswordOpen(false)}>
699
+ Cancel
700
+ </Button>
701
+ <Button onClick={handleChangePassword}>
702
+ Update Password
703
+ </Button>
704
+ </div>
705
+ </div>
706
+ </Dialog>
707
+
708
+ <ConfirmDialog
709
+ open={revokeSessionConfirmOpen}
710
+ onClose={() => { setRevokeSessionConfirmOpen(false); setSessionToRevoke(null); }}
711
+ onConfirm={handleRevokeSession}
712
+ title="Revoke Session"
713
+ description="Are you sure you want to revoke this session? The device will be signed out immediately."
714
+ confirmLabel="Revoke"
715
+ confirmVariant="danger"
716
+ />
717
+
718
+ <Dialog
719
+ open={deleteAccountConfirmOpen}
720
+ onClose={() => { setDeleteAccountConfirmOpen(false); setDeleteConfirmText(''); }}
721
+ title="Delete Account"
722
+ >
723
+ <div className="space-y-4">
724
+ <p className="text-sm text-gray-600">
725
+ This will permanently delete your account and all associated data. This action cannot be undone.
726
+ </p>
727
+ <Input
728
+ label={`Type "${APP_NAME}" to confirm`}
729
+ value={deleteConfirmText}
730
+ onChange={(e) => setDeleteConfirmText(e.target.value)}
731
+ placeholder={APP_NAME}
732
+ />
733
+ <div className="flex justify-end gap-3 pt-2">
734
+ <Button variant="outline" onClick={() => { setDeleteAccountConfirmOpen(false); setDeleteConfirmText(''); }}>
735
+ Cancel
736
+ </Button>
737
+ <Button
738
+ variant="danger"
739
+ disabled={deleteConfirmText !== APP_NAME}
740
+ onClick={handleDeleteAccount}
741
+ >
742
+ Delete Forever
743
+ </Button>
744
+ </div>
745
+ </div>
746
+ </Dialog>
747
+ </div>
748
+ );
749
+ }