@payez/next-mvp 4.1.7 → 4.1.9
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/README.md +792 -792
- package/dist/auth/better-auth.js +15 -3
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +1 -1
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +682 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +15 -3
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +110 -110
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
|
@@ -1,642 +1,642 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Enhanced Settings Page for @payez/next-mvp
|
|
3
|
-
*
|
|
4
|
-
* Implements BAPert's Member Self-Service spec with:
|
|
5
|
-
* - Appearance section (theme, compact mode)
|
|
6
|
-
* - Localization section (language, timezone, date format)
|
|
7
|
-
* - Notifications section (email and SMS preferences)
|
|
8
|
-
* - Privacy section (profile visibility, activity status)
|
|
9
|
-
*
|
|
10
|
-
* @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
'use client';
|
|
14
|
-
|
|
15
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
16
|
-
import { useLayout, useColors } from '../../theme/useTheme';
|
|
17
|
-
|
|
18
|
-
// Types matching BAPert's spec
|
|
19
|
-
interface PreferencesData {
|
|
20
|
-
appearance?: {
|
|
21
|
-
theme?: 'light' | 'dark' | 'system';
|
|
22
|
-
compact_mode?: boolean;
|
|
23
|
-
};
|
|
24
|
-
localization?: {
|
|
25
|
-
language?: string;
|
|
26
|
-
timezone?: string;
|
|
27
|
-
timezone_display?: string;
|
|
28
|
-
date_format?: string;
|
|
29
|
-
};
|
|
30
|
-
notifications?: {
|
|
31
|
-
email_enabled?: boolean;
|
|
32
|
-
email_security_alerts?: boolean;
|
|
33
|
-
email_account_updates?: boolean;
|
|
34
|
-
email_product_news?: boolean;
|
|
35
|
-
email_marketing?: boolean;
|
|
36
|
-
sms_enabled?: boolean;
|
|
37
|
-
sms_security_alerts?: boolean;
|
|
38
|
-
};
|
|
39
|
-
privacy?: {
|
|
40
|
-
profile_visibility?: 'private' | 'team' | 'public';
|
|
41
|
-
show_activity_status?: boolean;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface Language {
|
|
46
|
-
code: string;
|
|
47
|
-
name: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface Timezone {
|
|
51
|
-
id: string;
|
|
52
|
-
display: string;
|
|
53
|
-
offset: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Props for the settings page
|
|
57
|
-
interface EnhancedSettingsPageProps {
|
|
58
|
-
// Optional initial data (for SSR)
|
|
59
|
-
initialPreferences?: PreferencesData;
|
|
60
|
-
// API endpoints
|
|
61
|
-
preferencesEndpoint?: string;
|
|
62
|
-
languagesEndpoint?: string;
|
|
63
|
-
timezonesEndpoint?: string;
|
|
64
|
-
// Callbacks
|
|
65
|
-
onSave?: (preferences: PreferencesData) => Promise<void>;
|
|
66
|
-
onThemeChange?: (theme: 'light' | 'dark' | 'system') => void;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Common languages for fallback
|
|
70
|
-
const DEFAULT_LANGUAGES: Language[] = [
|
|
71
|
-
{ code: 'en', name: 'English (US)' },
|
|
72
|
-
{ code: 'en-GB', name: 'English (UK)' },
|
|
73
|
-
{ code: 'es', name: 'Spanish' },
|
|
74
|
-
{ code: 'fr', name: 'French' },
|
|
75
|
-
{ code: 'de', name: 'German' },
|
|
76
|
-
{ code: 'ja', name: 'Japanese' },
|
|
77
|
-
{ code: 'zh', name: 'Chinese (Simplified)' },
|
|
78
|
-
];
|
|
79
|
-
|
|
80
|
-
// Common timezones for fallback
|
|
81
|
-
const DEFAULT_TIMEZONES: Timezone[] = [
|
|
82
|
-
{ id: 'America/Los_Angeles', display: 'Pacific Time (US)', offset: '-08:00' },
|
|
83
|
-
{ id: 'America/Denver', display: 'Mountain Time (US)', offset: '-07:00' },
|
|
84
|
-
{ id: 'America/Chicago', display: 'Central Time (US)', offset: '-06:00' },
|
|
85
|
-
{ id: 'America/New_York', display: 'Eastern Time (US)', offset: '-05:00' },
|
|
86
|
-
{ id: 'Europe/London', display: 'London', offset: '+00:00' },
|
|
87
|
-
{ id: 'Europe/Paris', display: 'Paris', offset: '+01:00' },
|
|
88
|
-
{ id: 'Europe/Berlin', display: 'Berlin', offset: '+01:00' },
|
|
89
|
-
{ id: 'Asia/Tokyo', display: 'Tokyo', offset: '+09:00' },
|
|
90
|
-
{ id: 'Asia/Shanghai', display: 'Shanghai', offset: '+08:00' },
|
|
91
|
-
{ id: 'Australia/Sydney', display: 'Sydney', offset: '+11:00' },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
// Date format options
|
|
95
|
-
const DATE_FORMATS = [
|
|
96
|
-
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY', example: '01/09/2026' },
|
|
97
|
-
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY', example: '09/01/2026' },
|
|
98
|
-
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2026-01-09' },
|
|
99
|
-
];
|
|
100
|
-
|
|
101
|
-
// Toggle component
|
|
102
|
-
function Toggle({
|
|
103
|
-
enabled,
|
|
104
|
-
onChange,
|
|
105
|
-
disabled = false,
|
|
106
|
-
isDark = true,
|
|
107
|
-
}: {
|
|
108
|
-
enabled: boolean;
|
|
109
|
-
onChange: (value: boolean) => void;
|
|
110
|
-
disabled?: boolean;
|
|
111
|
-
isDark?: boolean;
|
|
112
|
-
}) {
|
|
113
|
-
return (
|
|
114
|
-
<button
|
|
115
|
-
type="button"
|
|
116
|
-
onClick={() => !disabled && onChange(!enabled)}
|
|
117
|
-
disabled={disabled}
|
|
118
|
-
className={`
|
|
119
|
-
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
|
120
|
-
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
121
|
-
${enabled ? 'bg-blue-600' : isDark ? 'bg-slate-600' : 'bg-gray-300'}
|
|
122
|
-
`}
|
|
123
|
-
>
|
|
124
|
-
<span
|
|
125
|
-
className={`
|
|
126
|
-
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
|
127
|
-
${enabled ? 'translate-x-6' : 'translate-x-1'}
|
|
128
|
-
`}
|
|
129
|
-
/>
|
|
130
|
-
</button>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Section wrapper component
|
|
135
|
-
function SettingsSection({
|
|
136
|
-
title,
|
|
137
|
-
children,
|
|
138
|
-
isDark,
|
|
139
|
-
}: {
|
|
140
|
-
title: string;
|
|
141
|
-
children: React.ReactNode;
|
|
142
|
-
isDark: boolean;
|
|
143
|
-
}) {
|
|
144
|
-
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
145
|
-
const titleColor = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
146
|
-
|
|
147
|
-
return (
|
|
148
|
-
<div className={`border-b ${borderColor} pb-6 mb-6 last:border-b-0`}>
|
|
149
|
-
<h2 className={`text-sm font-semibold uppercase tracking-wider ${titleColor} mb-4`}>
|
|
150
|
-
{title}
|
|
151
|
-
</h2>
|
|
152
|
-
{children}
|
|
153
|
-
</div>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Setting row component
|
|
158
|
-
function SettingRow({
|
|
159
|
-
label,
|
|
160
|
-
description,
|
|
161
|
-
children,
|
|
162
|
-
isDark,
|
|
163
|
-
}: {
|
|
164
|
-
label: string;
|
|
165
|
-
description?: string;
|
|
166
|
-
children: React.ReactNode;
|
|
167
|
-
isDark: boolean;
|
|
168
|
-
}) {
|
|
169
|
-
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
170
|
-
const textMuted = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<div className="flex items-center justify-between py-3">
|
|
174
|
-
<div className="flex-1">
|
|
175
|
-
<p className={`font-medium ${textPrimary}`}>{label}</p>
|
|
176
|
-
{description && <p className={`text-sm ${textMuted}`}>{description}</p>}
|
|
177
|
-
</div>
|
|
178
|
-
<div className="ml-4">{children}</div>
|
|
179
|
-
</div>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Select dropdown component
|
|
184
|
-
function Select({
|
|
185
|
-
value,
|
|
186
|
-
onChange,
|
|
187
|
-
options,
|
|
188
|
-
isDark,
|
|
189
|
-
}: {
|
|
190
|
-
value: string;
|
|
191
|
-
onChange: (value: string) => void;
|
|
192
|
-
options: { value: string; label: string }[];
|
|
193
|
-
isDark: boolean;
|
|
194
|
-
}) {
|
|
195
|
-
const bgColor = isDark ? 'bg-slate-700' : 'bg-white';
|
|
196
|
-
const borderColor = isDark ? 'border-slate-600' : 'border-gray-300';
|
|
197
|
-
const textColor = isDark ? 'text-white' : 'text-gray-900';
|
|
198
|
-
|
|
199
|
-
return (
|
|
200
|
-
<select
|
|
201
|
-
value={value}
|
|
202
|
-
onChange={(e) => onChange(e.target.value)}
|
|
203
|
-
className={`
|
|
204
|
-
px-3 py-2 rounded-md border ${borderColor} ${bgColor} ${textColor}
|
|
205
|
-
focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]
|
|
206
|
-
`}
|
|
207
|
-
>
|
|
208
|
-
{options.map((opt) => (
|
|
209
|
-
<option key={opt.value} value={opt.value}>
|
|
210
|
-
{opt.label}
|
|
211
|
-
</option>
|
|
212
|
-
))}
|
|
213
|
-
</select>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export default function EnhancedSettingsPage({
|
|
218
|
-
initialPreferences,
|
|
219
|
-
preferencesEndpoint = '/api/v1/user/preferences',
|
|
220
|
-
languagesEndpoint = '/api/v1/lookup/languages',
|
|
221
|
-
timezonesEndpoint = '/api/v1/lookup/timezones',
|
|
222
|
-
onSave,
|
|
223
|
-
onThemeChange,
|
|
224
|
-
}: EnhancedSettingsPageProps) {
|
|
225
|
-
const layout = useLayout();
|
|
226
|
-
const colors = useColors();
|
|
227
|
-
|
|
228
|
-
// Determine dark mode from theme colors
|
|
229
|
-
const isDark = colors?.background?.includes('slate-9') ||
|
|
230
|
-
colors?.background?.includes('gray-9') ||
|
|
231
|
-
colors?.card?.includes('slate-8');
|
|
232
|
-
|
|
233
|
-
// State
|
|
234
|
-
const [preferences, setPreferences] = useState<PreferencesData>(initialPreferences || {});
|
|
235
|
-
const [languages, setLanguages] = useState<Language[]>(DEFAULT_LANGUAGES);
|
|
236
|
-
const [timezones, setTimezones] = useState<Timezone[]>(DEFAULT_TIMEZONES);
|
|
237
|
-
const [loading, setLoading] = useState(!initialPreferences);
|
|
238
|
-
const [saving, setSaving] = useState(false);
|
|
239
|
-
const [error, setError] = useState<string | null>(null);
|
|
240
|
-
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
241
|
-
|
|
242
|
-
// Theme colors
|
|
243
|
-
const bgColor = isDark ? 'bg-slate-900' : 'bg-gray-50';
|
|
244
|
-
const cardBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
245
|
-
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
246
|
-
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
247
|
-
const textMuted = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
248
|
-
|
|
249
|
-
// Fetch preferences and lookups
|
|
250
|
-
useEffect(() => {
|
|
251
|
-
async function fetchData() {
|
|
252
|
-
try {
|
|
253
|
-
setLoading(true);
|
|
254
|
-
setError(null);
|
|
255
|
-
|
|
256
|
-
// Fetch preferences
|
|
257
|
-
const prefResponse = await fetch(preferencesEndpoint, { credentials: 'include' });
|
|
258
|
-
if (prefResponse.ok) {
|
|
259
|
-
const prefData = await prefResponse.json();
|
|
260
|
-
setPreferences(prefData);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Fetch languages (optional)
|
|
264
|
-
try {
|
|
265
|
-
const langResponse = await fetch(languagesEndpoint, { credentials: 'include' });
|
|
266
|
-
if (langResponse.ok) {
|
|
267
|
-
const langData = await langResponse.json();
|
|
268
|
-
if (langData.languages) setLanguages(langData.languages);
|
|
269
|
-
}
|
|
270
|
-
} catch {
|
|
271
|
-
// Use defaults
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Fetch timezones (optional)
|
|
275
|
-
try {
|
|
276
|
-
const tzResponse = await fetch(timezonesEndpoint, { credentials: 'include' });
|
|
277
|
-
if (tzResponse.ok) {
|
|
278
|
-
const tzData = await tzResponse.json();
|
|
279
|
-
if (tzData.timezones) setTimezones(tzData.timezones);
|
|
280
|
-
}
|
|
281
|
-
} catch {
|
|
282
|
-
// Use defaults
|
|
283
|
-
}
|
|
284
|
-
} catch (err) {
|
|
285
|
-
setError('Failed to load settings');
|
|
286
|
-
} finally {
|
|
287
|
-
setLoading(false);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (!initialPreferences) {
|
|
292
|
-
fetchData();
|
|
293
|
-
}
|
|
294
|
-
}, [preferencesEndpoint, languagesEndpoint, timezonesEndpoint, initialPreferences]);
|
|
295
|
-
|
|
296
|
-
// Update preference value
|
|
297
|
-
const updatePreference = useCallback(
|
|
298
|
-
<K extends keyof PreferencesData>(
|
|
299
|
-
section: K,
|
|
300
|
-
key: keyof NonNullable<PreferencesData[K]>,
|
|
301
|
-
value: unknown
|
|
302
|
-
) => {
|
|
303
|
-
setPreferences((prev) => ({
|
|
304
|
-
...prev,
|
|
305
|
-
[section]: {
|
|
306
|
-
...prev[section],
|
|
307
|
-
[key]: value,
|
|
308
|
-
},
|
|
309
|
-
}));
|
|
310
|
-
},
|
|
311
|
-
[]
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
// Save preferences
|
|
315
|
-
const handleSave = useCallback(async () => {
|
|
316
|
-
try {
|
|
317
|
-
setSaving(true);
|
|
318
|
-
setError(null);
|
|
319
|
-
setSuccessMessage(null);
|
|
320
|
-
|
|
321
|
-
if (onSave) {
|
|
322
|
-
await onSave(preferences);
|
|
323
|
-
} else {
|
|
324
|
-
const response = await fetch(preferencesEndpoint, {
|
|
325
|
-
method: 'PUT',
|
|
326
|
-
headers: { 'Content-Type': 'application/json' },
|
|
327
|
-
credentials: 'include',
|
|
328
|
-
body: JSON.stringify(preferences),
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
if (!response.ok) {
|
|
332
|
-
const data = await response.json();
|
|
333
|
-
throw new Error(data.message || 'Failed to save settings');
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
setSuccessMessage('Settings saved successfully');
|
|
338
|
-
setTimeout(() => setSuccessMessage(null), 3000);
|
|
339
|
-
} catch (err) {
|
|
340
|
-
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
|
341
|
-
} finally {
|
|
342
|
-
setSaving(false);
|
|
343
|
-
}
|
|
344
|
-
}, [preferences, preferencesEndpoint, onSave]);
|
|
345
|
-
|
|
346
|
-
// Handle theme change
|
|
347
|
-
const handleThemeChange = useCallback(
|
|
348
|
-
(theme: 'light' | 'dark' | 'system') => {
|
|
349
|
-
updatePreference('appearance', 'theme', theme);
|
|
350
|
-
onThemeChange?.(theme);
|
|
351
|
-
},
|
|
352
|
-
[updatePreference, onThemeChange]
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
// Loading state
|
|
356
|
-
if (loading) {
|
|
357
|
-
return (
|
|
358
|
-
<div className={`min-h-screen ${bgColor} flex items-center justify-center`}>
|
|
359
|
-
<div className="flex flex-col items-center space-y-4">
|
|
360
|
-
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
|
|
361
|
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
362
|
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
363
|
-
</svg>
|
|
364
|
-
<p className={textMuted}>Loading settings...</p>
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const currentTheme = preferences.appearance?.theme || 'system';
|
|
371
|
-
const compactMode = preferences.appearance?.compact_mode || false;
|
|
372
|
-
const currentLanguage = preferences.localization?.language || 'en';
|
|
373
|
-
const currentTimezone = preferences.localization?.timezone || 'America/Los_Angeles';
|
|
374
|
-
const currentDateFormat = preferences.localization?.date_format || 'MM/DD/YYYY';
|
|
375
|
-
const emailEnabled = preferences.notifications?.email_enabled ?? true;
|
|
376
|
-
const smsEnabled = preferences.notifications?.sms_enabled ?? false;
|
|
377
|
-
const profileVisibility = preferences.privacy?.profile_visibility || 'team';
|
|
378
|
-
const showActivityStatus = preferences.privacy?.show_activity_status ?? true;
|
|
379
|
-
|
|
380
|
-
return (
|
|
381
|
-
<div className={`min-h-screen ${bgColor}`}>
|
|
382
|
-
<div className={`max-w-2xl mx-auto ${layout?.padding || 'p-6'}`}>
|
|
383
|
-
{/* Page Header */}
|
|
384
|
-
<div className="flex items-center justify-between mb-6">
|
|
385
|
-
<h1 className={`text-3xl font-bold ${textPrimary}`}>Settings</h1>
|
|
386
|
-
<button
|
|
387
|
-
onClick={handleSave}
|
|
388
|
-
disabled={saving}
|
|
389
|
-
className={`
|
|
390
|
-
px-4 py-2 rounded-md font-medium transition-colors
|
|
391
|
-
${saving ? 'opacity-50 cursor-not-allowed' : ''}
|
|
392
|
-
bg-blue-600 text-white hover:bg-blue-700
|
|
393
|
-
`}
|
|
394
|
-
>
|
|
395
|
-
{saving ? 'Saving...' : 'Save Changes'}
|
|
396
|
-
</button>
|
|
397
|
-
</div>
|
|
398
|
-
|
|
399
|
-
{/* Messages */}
|
|
400
|
-
{error && (
|
|
401
|
-
<div className="mb-4 p-3 rounded-md bg-red-500/10 border border-red-500/50 text-red-500">
|
|
402
|
-
{error}
|
|
403
|
-
</div>
|
|
404
|
-
)}
|
|
405
|
-
{successMessage && (
|
|
406
|
-
<div className="mb-4 p-3 rounded-md bg-green-500/10 border border-green-500/50 text-green-500">
|
|
407
|
-
{successMessage}
|
|
408
|
-
</div>
|
|
409
|
-
)}
|
|
410
|
-
|
|
411
|
-
{/* Settings Card */}
|
|
412
|
-
<div className={`${cardBg} rounded-lg shadow-lg border ${borderColor} p-6`}>
|
|
413
|
-
{/* Appearance Section */}
|
|
414
|
-
<SettingsSection title="Appearance" isDark={isDark}>
|
|
415
|
-
{/* Theme Selection */}
|
|
416
|
-
<div className="mb-4">
|
|
417
|
-
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Theme</label>
|
|
418
|
-
<div className="flex gap-2">
|
|
419
|
-
{(['light', 'dark', 'system'] as const).map((theme) => (
|
|
420
|
-
<button
|
|
421
|
-
key={theme}
|
|
422
|
-
onClick={() => handleThemeChange(theme)}
|
|
423
|
-
className={`
|
|
424
|
-
px-4 py-2 rounded-md capitalize transition-colors
|
|
425
|
-
${currentTheme === theme
|
|
426
|
-
? 'bg-blue-600 text-white'
|
|
427
|
-
: isDark
|
|
428
|
-
? 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
|
429
|
-
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
430
|
-
}
|
|
431
|
-
`}
|
|
432
|
-
>
|
|
433
|
-
{theme}
|
|
434
|
-
</button>
|
|
435
|
-
))}
|
|
436
|
-
</div>
|
|
437
|
-
</div>
|
|
438
|
-
|
|
439
|
-
{/* Compact Mode */}
|
|
440
|
-
<SettingRow
|
|
441
|
-
label="Compact Mode"
|
|
442
|
-
description="Reduce spacing and padding for denser layouts"
|
|
443
|
-
isDark={isDark}
|
|
444
|
-
>
|
|
445
|
-
<Toggle
|
|
446
|
-
enabled={compactMode}
|
|
447
|
-
onChange={(value) => updatePreference('appearance', 'compact_mode', value)}
|
|
448
|
-
isDark={isDark}
|
|
449
|
-
/>
|
|
450
|
-
</SettingRow>
|
|
451
|
-
</SettingsSection>
|
|
452
|
-
|
|
453
|
-
{/* Localization Section */}
|
|
454
|
-
<SettingsSection title="Localization" isDark={isDark}>
|
|
455
|
-
<div className="space-y-4">
|
|
456
|
-
{/* Language */}
|
|
457
|
-
<div>
|
|
458
|
-
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Language</label>
|
|
459
|
-
<Select
|
|
460
|
-
value={currentLanguage}
|
|
461
|
-
onChange={(value) => updatePreference('localization', 'language', value)}
|
|
462
|
-
options={languages.map((l) => ({ value: l.code, label: l.name }))}
|
|
463
|
-
isDark={isDark}
|
|
464
|
-
/>
|
|
465
|
-
</div>
|
|
466
|
-
|
|
467
|
-
{/* Timezone */}
|
|
468
|
-
<div>
|
|
469
|
-
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Timezone</label>
|
|
470
|
-
<Select
|
|
471
|
-
value={currentTimezone}
|
|
472
|
-
onChange={(value) => updatePreference('localization', 'timezone', value)}
|
|
473
|
-
options={timezones.map((tz) => ({ value: tz.id, label: `${tz.display} (${tz.offset})` }))}
|
|
474
|
-
isDark={isDark}
|
|
475
|
-
/>
|
|
476
|
-
{preferences.localization?.timezone_display && (
|
|
477
|
-
<p className={`mt-1 text-sm ${textMuted}`}>
|
|
478
|
-
Currently: {preferences.localization.timezone_display}
|
|
479
|
-
</p>
|
|
480
|
-
)}
|
|
481
|
-
</div>
|
|
482
|
-
|
|
483
|
-
{/* Date Format */}
|
|
484
|
-
<div>
|
|
485
|
-
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Date Format</label>
|
|
486
|
-
<Select
|
|
487
|
-
value={currentDateFormat}
|
|
488
|
-
onChange={(value) => updatePreference('localization', 'date_format', value)}
|
|
489
|
-
options={DATE_FORMATS.map((df) => ({ value: df.value, label: df.label }))}
|
|
490
|
-
isDark={isDark}
|
|
491
|
-
/>
|
|
492
|
-
<p className={`mt-1 text-sm ${textMuted}`}>
|
|
493
|
-
Example: {DATE_FORMATS.find((df) => df.value === currentDateFormat)?.example}
|
|
494
|
-
</p>
|
|
495
|
-
</div>
|
|
496
|
-
</div>
|
|
497
|
-
</SettingsSection>
|
|
498
|
-
|
|
499
|
-
{/* Notifications Section */}
|
|
500
|
-
<SettingsSection title="Notifications" isDark={isDark}>
|
|
501
|
-
{/* Email Notifications */}
|
|
502
|
-
<SettingRow
|
|
503
|
-
label="Email Notifications"
|
|
504
|
-
description="Receive updates and alerts via email"
|
|
505
|
-
isDark={isDark}
|
|
506
|
-
>
|
|
507
|
-
<Toggle
|
|
508
|
-
enabled={emailEnabled}
|
|
509
|
-
onChange={(value) => updatePreference('notifications', 'email_enabled', value)}
|
|
510
|
-
isDark={isDark}
|
|
511
|
-
/>
|
|
512
|
-
</SettingRow>
|
|
513
|
-
|
|
514
|
-
{emailEnabled && (
|
|
515
|
-
<div className={`ml-6 border-l-2 ${borderColor} pl-4 space-y-2`}>
|
|
516
|
-
<SettingRow label="Security alerts" description="Login from new device, etc." isDark={isDark}>
|
|
517
|
-
<Toggle
|
|
518
|
-
enabled={preferences.notifications?.email_security_alerts ?? true}
|
|
519
|
-
onChange={(value) => updatePreference('notifications', 'email_security_alerts', value)}
|
|
520
|
-
isDark={isDark}
|
|
521
|
-
/>
|
|
522
|
-
</SettingRow>
|
|
523
|
-
<SettingRow label="Account updates" description="Password expiry, etc." isDark={isDark}>
|
|
524
|
-
<Toggle
|
|
525
|
-
enabled={preferences.notifications?.email_account_updates ?? true}
|
|
526
|
-
onChange={(value) => updatePreference('notifications', 'email_account_updates', value)}
|
|
527
|
-
isDark={isDark}
|
|
528
|
-
/>
|
|
529
|
-
</SettingRow>
|
|
530
|
-
<SettingRow label="Product news and tips" isDark={isDark}>
|
|
531
|
-
<Toggle
|
|
532
|
-
enabled={preferences.notifications?.email_product_news ?? false}
|
|
533
|
-
onChange={(value) => updatePreference('notifications', 'email_product_news', value)}
|
|
534
|
-
isDark={isDark}
|
|
535
|
-
/>
|
|
536
|
-
</SettingRow>
|
|
537
|
-
<SettingRow label="Marketing and promotions" isDark={isDark}>
|
|
538
|
-
<Toggle
|
|
539
|
-
enabled={preferences.notifications?.email_marketing ?? false}
|
|
540
|
-
onChange={(value) => updatePreference('notifications', 'email_marketing', value)}
|
|
541
|
-
isDark={isDark}
|
|
542
|
-
/>
|
|
543
|
-
</SettingRow>
|
|
544
|
-
</div>
|
|
545
|
-
)}
|
|
546
|
-
|
|
547
|
-
{/* SMS Notifications */}
|
|
548
|
-
<SettingRow
|
|
549
|
-
label="SMS Notifications"
|
|
550
|
-
description="Receive urgent alerts via text message"
|
|
551
|
-
isDark={isDark}
|
|
552
|
-
>
|
|
553
|
-
<Toggle
|
|
554
|
-
enabled={smsEnabled}
|
|
555
|
-
onChange={(value) => updatePreference('notifications', 'sms_enabled', value)}
|
|
556
|
-
isDark={isDark}
|
|
557
|
-
/>
|
|
558
|
-
</SettingRow>
|
|
559
|
-
|
|
560
|
-
{smsEnabled && (
|
|
561
|
-
<div className={`ml-6 border-l-2 ${borderColor} pl-4`}>
|
|
562
|
-
<SettingRow label="Security alerts only" isDark={isDark}>
|
|
563
|
-
<Toggle
|
|
564
|
-
enabled={preferences.notifications?.sms_security_alerts ?? true}
|
|
565
|
-
onChange={(value) => updatePreference('notifications', 'sms_security_alerts', value)}
|
|
566
|
-
isDark={isDark}
|
|
567
|
-
/>
|
|
568
|
-
</SettingRow>
|
|
569
|
-
</div>
|
|
570
|
-
)}
|
|
571
|
-
</SettingsSection>
|
|
572
|
-
|
|
573
|
-
{/* Privacy Section */}
|
|
574
|
-
<SettingsSection title="Privacy" isDark={isDark}>
|
|
575
|
-
{/* Profile Visibility */}
|
|
576
|
-
<div className="mb-4">
|
|
577
|
-
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Profile Visibility</label>
|
|
578
|
-
<div className="space-y-2">
|
|
579
|
-
{([
|
|
580
|
-
{ value: 'private', label: 'Private', description: 'Only you can see your profile' },
|
|
581
|
-
{ value: 'team', label: 'Team', description: 'Team members can see your profile' },
|
|
582
|
-
{ value: 'public', label: 'Public', description: 'Anyone can see your profile' },
|
|
583
|
-
] as const).map((option) => (
|
|
584
|
-
<label
|
|
585
|
-
key={option.value}
|
|
586
|
-
className={`flex items-center p-3 rounded-md cursor-pointer transition-colors ${
|
|
587
|
-
profileVisibility === option.value
|
|
588
|
-
? isDark
|
|
589
|
-
? 'bg-blue-600/20 border border-blue-500'
|
|
590
|
-
: 'bg-blue-50 border border-blue-500'
|
|
591
|
-
: isDark
|
|
592
|
-
? 'bg-slate-700 hover:bg-slate-600'
|
|
593
|
-
: 'bg-gray-50 hover:bg-gray-100'
|
|
594
|
-
}`}
|
|
595
|
-
>
|
|
596
|
-
<input
|
|
597
|
-
type="radio"
|
|
598
|
-
name="profileVisibility"
|
|
599
|
-
value={option.value}
|
|
600
|
-
checked={profileVisibility === option.value}
|
|
601
|
-
onChange={(e) =>
|
|
602
|
-
updatePreference('privacy', 'profile_visibility', e.target.value as 'private' | 'team' | 'public')
|
|
603
|
-
}
|
|
604
|
-
className="mr-3"
|
|
605
|
-
/>
|
|
606
|
-
<div>
|
|
607
|
-
<p className={`font-medium ${textPrimary}`}>{option.label}</p>
|
|
608
|
-
<p className={`text-sm ${textMuted}`}>{option.description}</p>
|
|
609
|
-
</div>
|
|
610
|
-
</label>
|
|
611
|
-
))}
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
|
|
615
|
-
{/* Activity Status */}
|
|
616
|
-
<SettingRow
|
|
617
|
-
label="Activity Status"
|
|
618
|
-
description="Show when you're active to team members"
|
|
619
|
-
isDark={isDark}
|
|
620
|
-
>
|
|
621
|
-
<Toggle
|
|
622
|
-
enabled={showActivityStatus}
|
|
623
|
-
onChange={(value) => updatePreference('privacy', 'show_activity_status', value)}
|
|
624
|
-
isDark={isDark}
|
|
625
|
-
/>
|
|
626
|
-
</SettingRow>
|
|
627
|
-
</SettingsSection>
|
|
628
|
-
</div>
|
|
629
|
-
|
|
630
|
-
{/* Navigation Links */}
|
|
631
|
-
<div className="mt-6 flex justify-between">
|
|
632
|
-
<a href="/account/security" className={`text-sm hover:underline ${textMuted}`}>
|
|
633
|
-
← Security Settings
|
|
634
|
-
</a>
|
|
635
|
-
<a href="/account/profile" className={`text-sm hover:underline ${textMuted}`}>
|
|
636
|
-
Back to Profile →
|
|
637
|
-
</a>
|
|
638
|
-
</div>
|
|
639
|
-
</div>
|
|
640
|
-
</div>
|
|
641
|
-
);
|
|
642
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Settings Page for @payez/next-mvp
|
|
3
|
+
*
|
|
4
|
+
* Implements BAPert's Member Self-Service spec with:
|
|
5
|
+
* - Appearance section (theme, compact mode)
|
|
6
|
+
* - Localization section (language, timezone, date format)
|
|
7
|
+
* - Notifications section (email and SMS preferences)
|
|
8
|
+
* - Privacy section (profile visibility, activity status)
|
|
9
|
+
*
|
|
10
|
+
* @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use client';
|
|
14
|
+
|
|
15
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
16
|
+
import { useLayout, useColors } from '../../theme/useTheme';
|
|
17
|
+
|
|
18
|
+
// Types matching BAPert's spec
|
|
19
|
+
interface PreferencesData {
|
|
20
|
+
appearance?: {
|
|
21
|
+
theme?: 'light' | 'dark' | 'system';
|
|
22
|
+
compact_mode?: boolean;
|
|
23
|
+
};
|
|
24
|
+
localization?: {
|
|
25
|
+
language?: string;
|
|
26
|
+
timezone?: string;
|
|
27
|
+
timezone_display?: string;
|
|
28
|
+
date_format?: string;
|
|
29
|
+
};
|
|
30
|
+
notifications?: {
|
|
31
|
+
email_enabled?: boolean;
|
|
32
|
+
email_security_alerts?: boolean;
|
|
33
|
+
email_account_updates?: boolean;
|
|
34
|
+
email_product_news?: boolean;
|
|
35
|
+
email_marketing?: boolean;
|
|
36
|
+
sms_enabled?: boolean;
|
|
37
|
+
sms_security_alerts?: boolean;
|
|
38
|
+
};
|
|
39
|
+
privacy?: {
|
|
40
|
+
profile_visibility?: 'private' | 'team' | 'public';
|
|
41
|
+
show_activity_status?: boolean;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Language {
|
|
46
|
+
code: string;
|
|
47
|
+
name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Timezone {
|
|
51
|
+
id: string;
|
|
52
|
+
display: string;
|
|
53
|
+
offset: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Props for the settings page
|
|
57
|
+
interface EnhancedSettingsPageProps {
|
|
58
|
+
// Optional initial data (for SSR)
|
|
59
|
+
initialPreferences?: PreferencesData;
|
|
60
|
+
// API endpoints
|
|
61
|
+
preferencesEndpoint?: string;
|
|
62
|
+
languagesEndpoint?: string;
|
|
63
|
+
timezonesEndpoint?: string;
|
|
64
|
+
// Callbacks
|
|
65
|
+
onSave?: (preferences: PreferencesData) => Promise<void>;
|
|
66
|
+
onThemeChange?: (theme: 'light' | 'dark' | 'system') => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Common languages for fallback
|
|
70
|
+
const DEFAULT_LANGUAGES: Language[] = [
|
|
71
|
+
{ code: 'en', name: 'English (US)' },
|
|
72
|
+
{ code: 'en-GB', name: 'English (UK)' },
|
|
73
|
+
{ code: 'es', name: 'Spanish' },
|
|
74
|
+
{ code: 'fr', name: 'French' },
|
|
75
|
+
{ code: 'de', name: 'German' },
|
|
76
|
+
{ code: 'ja', name: 'Japanese' },
|
|
77
|
+
{ code: 'zh', name: 'Chinese (Simplified)' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// Common timezones for fallback
|
|
81
|
+
const DEFAULT_TIMEZONES: Timezone[] = [
|
|
82
|
+
{ id: 'America/Los_Angeles', display: 'Pacific Time (US)', offset: '-08:00' },
|
|
83
|
+
{ id: 'America/Denver', display: 'Mountain Time (US)', offset: '-07:00' },
|
|
84
|
+
{ id: 'America/Chicago', display: 'Central Time (US)', offset: '-06:00' },
|
|
85
|
+
{ id: 'America/New_York', display: 'Eastern Time (US)', offset: '-05:00' },
|
|
86
|
+
{ id: 'Europe/London', display: 'London', offset: '+00:00' },
|
|
87
|
+
{ id: 'Europe/Paris', display: 'Paris', offset: '+01:00' },
|
|
88
|
+
{ id: 'Europe/Berlin', display: 'Berlin', offset: '+01:00' },
|
|
89
|
+
{ id: 'Asia/Tokyo', display: 'Tokyo', offset: '+09:00' },
|
|
90
|
+
{ id: 'Asia/Shanghai', display: 'Shanghai', offset: '+08:00' },
|
|
91
|
+
{ id: 'Australia/Sydney', display: 'Sydney', offset: '+11:00' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// Date format options
|
|
95
|
+
const DATE_FORMATS = [
|
|
96
|
+
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY', example: '01/09/2026' },
|
|
97
|
+
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY', example: '09/01/2026' },
|
|
98
|
+
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2026-01-09' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// Toggle component
|
|
102
|
+
function Toggle({
|
|
103
|
+
enabled,
|
|
104
|
+
onChange,
|
|
105
|
+
disabled = false,
|
|
106
|
+
isDark = true,
|
|
107
|
+
}: {
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
onChange: (value: boolean) => void;
|
|
110
|
+
disabled?: boolean;
|
|
111
|
+
isDark?: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => !disabled && onChange(!enabled)}
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
className={`
|
|
119
|
+
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
|
120
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
121
|
+
${enabled ? 'bg-blue-600' : isDark ? 'bg-slate-600' : 'bg-gray-300'}
|
|
122
|
+
`}
|
|
123
|
+
>
|
|
124
|
+
<span
|
|
125
|
+
className={`
|
|
126
|
+
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
|
127
|
+
${enabled ? 'translate-x-6' : 'translate-x-1'}
|
|
128
|
+
`}
|
|
129
|
+
/>
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Section wrapper component
|
|
135
|
+
function SettingsSection({
|
|
136
|
+
title,
|
|
137
|
+
children,
|
|
138
|
+
isDark,
|
|
139
|
+
}: {
|
|
140
|
+
title: string;
|
|
141
|
+
children: React.ReactNode;
|
|
142
|
+
isDark: boolean;
|
|
143
|
+
}) {
|
|
144
|
+
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
145
|
+
const titleColor = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className={`border-b ${borderColor} pb-6 mb-6 last:border-b-0`}>
|
|
149
|
+
<h2 className={`text-sm font-semibold uppercase tracking-wider ${titleColor} mb-4`}>
|
|
150
|
+
{title}
|
|
151
|
+
</h2>
|
|
152
|
+
{children}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Setting row component
|
|
158
|
+
function SettingRow({
|
|
159
|
+
label,
|
|
160
|
+
description,
|
|
161
|
+
children,
|
|
162
|
+
isDark,
|
|
163
|
+
}: {
|
|
164
|
+
label: string;
|
|
165
|
+
description?: string;
|
|
166
|
+
children: React.ReactNode;
|
|
167
|
+
isDark: boolean;
|
|
168
|
+
}) {
|
|
169
|
+
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
170
|
+
const textMuted = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex items-center justify-between py-3">
|
|
174
|
+
<div className="flex-1">
|
|
175
|
+
<p className={`font-medium ${textPrimary}`}>{label}</p>
|
|
176
|
+
{description && <p className={`text-sm ${textMuted}`}>{description}</p>}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="ml-4">{children}</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Select dropdown component
|
|
184
|
+
function Select({
|
|
185
|
+
value,
|
|
186
|
+
onChange,
|
|
187
|
+
options,
|
|
188
|
+
isDark,
|
|
189
|
+
}: {
|
|
190
|
+
value: string;
|
|
191
|
+
onChange: (value: string) => void;
|
|
192
|
+
options: { value: string; label: string }[];
|
|
193
|
+
isDark: boolean;
|
|
194
|
+
}) {
|
|
195
|
+
const bgColor = isDark ? 'bg-slate-700' : 'bg-white';
|
|
196
|
+
const borderColor = isDark ? 'border-slate-600' : 'border-gray-300';
|
|
197
|
+
const textColor = isDark ? 'text-white' : 'text-gray-900';
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<select
|
|
201
|
+
value={value}
|
|
202
|
+
onChange={(e) => onChange(e.target.value)}
|
|
203
|
+
className={`
|
|
204
|
+
px-3 py-2 rounded-md border ${borderColor} ${bgColor} ${textColor}
|
|
205
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]
|
|
206
|
+
`}
|
|
207
|
+
>
|
|
208
|
+
{options.map((opt) => (
|
|
209
|
+
<option key={opt.value} value={opt.value}>
|
|
210
|
+
{opt.label}
|
|
211
|
+
</option>
|
|
212
|
+
))}
|
|
213
|
+
</select>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default function EnhancedSettingsPage({
|
|
218
|
+
initialPreferences,
|
|
219
|
+
preferencesEndpoint = '/api/v1/user/preferences',
|
|
220
|
+
languagesEndpoint = '/api/v1/lookup/languages',
|
|
221
|
+
timezonesEndpoint = '/api/v1/lookup/timezones',
|
|
222
|
+
onSave,
|
|
223
|
+
onThemeChange,
|
|
224
|
+
}: EnhancedSettingsPageProps) {
|
|
225
|
+
const layout = useLayout();
|
|
226
|
+
const colors = useColors();
|
|
227
|
+
|
|
228
|
+
// Determine dark mode from theme colors
|
|
229
|
+
const isDark = colors?.background?.includes('slate-9') ||
|
|
230
|
+
colors?.background?.includes('gray-9') ||
|
|
231
|
+
colors?.card?.includes('slate-8');
|
|
232
|
+
|
|
233
|
+
// State
|
|
234
|
+
const [preferences, setPreferences] = useState<PreferencesData>(initialPreferences || {});
|
|
235
|
+
const [languages, setLanguages] = useState<Language[]>(DEFAULT_LANGUAGES);
|
|
236
|
+
const [timezones, setTimezones] = useState<Timezone[]>(DEFAULT_TIMEZONES);
|
|
237
|
+
const [loading, setLoading] = useState(!initialPreferences);
|
|
238
|
+
const [saving, setSaving] = useState(false);
|
|
239
|
+
const [error, setError] = useState<string | null>(null);
|
|
240
|
+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
241
|
+
|
|
242
|
+
// Theme colors
|
|
243
|
+
const bgColor = isDark ? 'bg-slate-900' : 'bg-gray-50';
|
|
244
|
+
const cardBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
245
|
+
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
246
|
+
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
247
|
+
const textMuted = isDark ? 'text-gray-400' : 'text-gray-500';
|
|
248
|
+
|
|
249
|
+
// Fetch preferences and lookups
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
async function fetchData() {
|
|
252
|
+
try {
|
|
253
|
+
setLoading(true);
|
|
254
|
+
setError(null);
|
|
255
|
+
|
|
256
|
+
// Fetch preferences
|
|
257
|
+
const prefResponse = await fetch(preferencesEndpoint, { credentials: 'include' });
|
|
258
|
+
if (prefResponse.ok) {
|
|
259
|
+
const prefData = await prefResponse.json();
|
|
260
|
+
setPreferences(prefData);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fetch languages (optional)
|
|
264
|
+
try {
|
|
265
|
+
const langResponse = await fetch(languagesEndpoint, { credentials: 'include' });
|
|
266
|
+
if (langResponse.ok) {
|
|
267
|
+
const langData = await langResponse.json();
|
|
268
|
+
if (langData.languages) setLanguages(langData.languages);
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Use defaults
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Fetch timezones (optional)
|
|
275
|
+
try {
|
|
276
|
+
const tzResponse = await fetch(timezonesEndpoint, { credentials: 'include' });
|
|
277
|
+
if (tzResponse.ok) {
|
|
278
|
+
const tzData = await tzResponse.json();
|
|
279
|
+
if (tzData.timezones) setTimezones(tzData.timezones);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Use defaults
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
setError('Failed to load settings');
|
|
286
|
+
} finally {
|
|
287
|
+
setLoading(false);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!initialPreferences) {
|
|
292
|
+
fetchData();
|
|
293
|
+
}
|
|
294
|
+
}, [preferencesEndpoint, languagesEndpoint, timezonesEndpoint, initialPreferences]);
|
|
295
|
+
|
|
296
|
+
// Update preference value
|
|
297
|
+
const updatePreference = useCallback(
|
|
298
|
+
<K extends keyof PreferencesData>(
|
|
299
|
+
section: K,
|
|
300
|
+
key: keyof NonNullable<PreferencesData[K]>,
|
|
301
|
+
value: unknown
|
|
302
|
+
) => {
|
|
303
|
+
setPreferences((prev) => ({
|
|
304
|
+
...prev,
|
|
305
|
+
[section]: {
|
|
306
|
+
...prev[section],
|
|
307
|
+
[key]: value,
|
|
308
|
+
},
|
|
309
|
+
}));
|
|
310
|
+
},
|
|
311
|
+
[]
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Save preferences
|
|
315
|
+
const handleSave = useCallback(async () => {
|
|
316
|
+
try {
|
|
317
|
+
setSaving(true);
|
|
318
|
+
setError(null);
|
|
319
|
+
setSuccessMessage(null);
|
|
320
|
+
|
|
321
|
+
if (onSave) {
|
|
322
|
+
await onSave(preferences);
|
|
323
|
+
} else {
|
|
324
|
+
const response = await fetch(preferencesEndpoint, {
|
|
325
|
+
method: 'PUT',
|
|
326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
327
|
+
credentials: 'include',
|
|
328
|
+
body: JSON.stringify(preferences),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
const data = await response.json();
|
|
333
|
+
throw new Error(data.message || 'Failed to save settings');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
setSuccessMessage('Settings saved successfully');
|
|
338
|
+
setTimeout(() => setSuccessMessage(null), 3000);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
|
341
|
+
} finally {
|
|
342
|
+
setSaving(false);
|
|
343
|
+
}
|
|
344
|
+
}, [preferences, preferencesEndpoint, onSave]);
|
|
345
|
+
|
|
346
|
+
// Handle theme change
|
|
347
|
+
const handleThemeChange = useCallback(
|
|
348
|
+
(theme: 'light' | 'dark' | 'system') => {
|
|
349
|
+
updatePreference('appearance', 'theme', theme);
|
|
350
|
+
onThemeChange?.(theme);
|
|
351
|
+
},
|
|
352
|
+
[updatePreference, onThemeChange]
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Loading state
|
|
356
|
+
if (loading) {
|
|
357
|
+
return (
|
|
358
|
+
<div className={`min-h-screen ${bgColor} flex items-center justify-center`}>
|
|
359
|
+
<div className="flex flex-col items-center space-y-4">
|
|
360
|
+
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
|
|
361
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
362
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
363
|
+
</svg>
|
|
364
|
+
<p className={textMuted}>Loading settings...</p>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const currentTheme = preferences.appearance?.theme || 'system';
|
|
371
|
+
const compactMode = preferences.appearance?.compact_mode || false;
|
|
372
|
+
const currentLanguage = preferences.localization?.language || 'en';
|
|
373
|
+
const currentTimezone = preferences.localization?.timezone || 'America/Los_Angeles';
|
|
374
|
+
const currentDateFormat = preferences.localization?.date_format || 'MM/DD/YYYY';
|
|
375
|
+
const emailEnabled = preferences.notifications?.email_enabled ?? true;
|
|
376
|
+
const smsEnabled = preferences.notifications?.sms_enabled ?? false;
|
|
377
|
+
const profileVisibility = preferences.privacy?.profile_visibility || 'team';
|
|
378
|
+
const showActivityStatus = preferences.privacy?.show_activity_status ?? true;
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<div className={`min-h-screen ${bgColor}`}>
|
|
382
|
+
<div className={`max-w-2xl mx-auto ${layout?.padding || 'p-6'}`}>
|
|
383
|
+
{/* Page Header */}
|
|
384
|
+
<div className="flex items-center justify-between mb-6">
|
|
385
|
+
<h1 className={`text-3xl font-bold ${textPrimary}`}>Settings</h1>
|
|
386
|
+
<button
|
|
387
|
+
onClick={handleSave}
|
|
388
|
+
disabled={saving}
|
|
389
|
+
className={`
|
|
390
|
+
px-4 py-2 rounded-md font-medium transition-colors
|
|
391
|
+
${saving ? 'opacity-50 cursor-not-allowed' : ''}
|
|
392
|
+
bg-blue-600 text-white hover:bg-blue-700
|
|
393
|
+
`}
|
|
394
|
+
>
|
|
395
|
+
{saving ? 'Saving...' : 'Save Changes'}
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
{/* Messages */}
|
|
400
|
+
{error && (
|
|
401
|
+
<div className="mb-4 p-3 rounded-md bg-red-500/10 border border-red-500/50 text-red-500">
|
|
402
|
+
{error}
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
{successMessage && (
|
|
406
|
+
<div className="mb-4 p-3 rounded-md bg-green-500/10 border border-green-500/50 text-green-500">
|
|
407
|
+
{successMessage}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
{/* Settings Card */}
|
|
412
|
+
<div className={`${cardBg} rounded-lg shadow-lg border ${borderColor} p-6`}>
|
|
413
|
+
{/* Appearance Section */}
|
|
414
|
+
<SettingsSection title="Appearance" isDark={isDark}>
|
|
415
|
+
{/* Theme Selection */}
|
|
416
|
+
<div className="mb-4">
|
|
417
|
+
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Theme</label>
|
|
418
|
+
<div className="flex gap-2">
|
|
419
|
+
{(['light', 'dark', 'system'] as const).map((theme) => (
|
|
420
|
+
<button
|
|
421
|
+
key={theme}
|
|
422
|
+
onClick={() => handleThemeChange(theme)}
|
|
423
|
+
className={`
|
|
424
|
+
px-4 py-2 rounded-md capitalize transition-colors
|
|
425
|
+
${currentTheme === theme
|
|
426
|
+
? 'bg-blue-600 text-white'
|
|
427
|
+
: isDark
|
|
428
|
+
? 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
|
429
|
+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
430
|
+
}
|
|
431
|
+
`}
|
|
432
|
+
>
|
|
433
|
+
{theme}
|
|
434
|
+
</button>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
{/* Compact Mode */}
|
|
440
|
+
<SettingRow
|
|
441
|
+
label="Compact Mode"
|
|
442
|
+
description="Reduce spacing and padding for denser layouts"
|
|
443
|
+
isDark={isDark}
|
|
444
|
+
>
|
|
445
|
+
<Toggle
|
|
446
|
+
enabled={compactMode}
|
|
447
|
+
onChange={(value) => updatePreference('appearance', 'compact_mode', value)}
|
|
448
|
+
isDark={isDark}
|
|
449
|
+
/>
|
|
450
|
+
</SettingRow>
|
|
451
|
+
</SettingsSection>
|
|
452
|
+
|
|
453
|
+
{/* Localization Section */}
|
|
454
|
+
<SettingsSection title="Localization" isDark={isDark}>
|
|
455
|
+
<div className="space-y-4">
|
|
456
|
+
{/* Language */}
|
|
457
|
+
<div>
|
|
458
|
+
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Language</label>
|
|
459
|
+
<Select
|
|
460
|
+
value={currentLanguage}
|
|
461
|
+
onChange={(value) => updatePreference('localization', 'language', value)}
|
|
462
|
+
options={languages.map((l) => ({ value: l.code, label: l.name }))}
|
|
463
|
+
isDark={isDark}
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
{/* Timezone */}
|
|
468
|
+
<div>
|
|
469
|
+
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Timezone</label>
|
|
470
|
+
<Select
|
|
471
|
+
value={currentTimezone}
|
|
472
|
+
onChange={(value) => updatePreference('localization', 'timezone', value)}
|
|
473
|
+
options={timezones.map((tz) => ({ value: tz.id, label: `${tz.display} (${tz.offset})` }))}
|
|
474
|
+
isDark={isDark}
|
|
475
|
+
/>
|
|
476
|
+
{preferences.localization?.timezone_display && (
|
|
477
|
+
<p className={`mt-1 text-sm ${textMuted}`}>
|
|
478
|
+
Currently: {preferences.localization.timezone_display}
|
|
479
|
+
</p>
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{/* Date Format */}
|
|
484
|
+
<div>
|
|
485
|
+
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Date Format</label>
|
|
486
|
+
<Select
|
|
487
|
+
value={currentDateFormat}
|
|
488
|
+
onChange={(value) => updatePreference('localization', 'date_format', value)}
|
|
489
|
+
options={DATE_FORMATS.map((df) => ({ value: df.value, label: df.label }))}
|
|
490
|
+
isDark={isDark}
|
|
491
|
+
/>
|
|
492
|
+
<p className={`mt-1 text-sm ${textMuted}`}>
|
|
493
|
+
Example: {DATE_FORMATS.find((df) => df.value === currentDateFormat)?.example}
|
|
494
|
+
</p>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</SettingsSection>
|
|
498
|
+
|
|
499
|
+
{/* Notifications Section */}
|
|
500
|
+
<SettingsSection title="Notifications" isDark={isDark}>
|
|
501
|
+
{/* Email Notifications */}
|
|
502
|
+
<SettingRow
|
|
503
|
+
label="Email Notifications"
|
|
504
|
+
description="Receive updates and alerts via email"
|
|
505
|
+
isDark={isDark}
|
|
506
|
+
>
|
|
507
|
+
<Toggle
|
|
508
|
+
enabled={emailEnabled}
|
|
509
|
+
onChange={(value) => updatePreference('notifications', 'email_enabled', value)}
|
|
510
|
+
isDark={isDark}
|
|
511
|
+
/>
|
|
512
|
+
</SettingRow>
|
|
513
|
+
|
|
514
|
+
{emailEnabled && (
|
|
515
|
+
<div className={`ml-6 border-l-2 ${borderColor} pl-4 space-y-2`}>
|
|
516
|
+
<SettingRow label="Security alerts" description="Login from new device, etc." isDark={isDark}>
|
|
517
|
+
<Toggle
|
|
518
|
+
enabled={preferences.notifications?.email_security_alerts ?? true}
|
|
519
|
+
onChange={(value) => updatePreference('notifications', 'email_security_alerts', value)}
|
|
520
|
+
isDark={isDark}
|
|
521
|
+
/>
|
|
522
|
+
</SettingRow>
|
|
523
|
+
<SettingRow label="Account updates" description="Password expiry, etc." isDark={isDark}>
|
|
524
|
+
<Toggle
|
|
525
|
+
enabled={preferences.notifications?.email_account_updates ?? true}
|
|
526
|
+
onChange={(value) => updatePreference('notifications', 'email_account_updates', value)}
|
|
527
|
+
isDark={isDark}
|
|
528
|
+
/>
|
|
529
|
+
</SettingRow>
|
|
530
|
+
<SettingRow label="Product news and tips" isDark={isDark}>
|
|
531
|
+
<Toggle
|
|
532
|
+
enabled={preferences.notifications?.email_product_news ?? false}
|
|
533
|
+
onChange={(value) => updatePreference('notifications', 'email_product_news', value)}
|
|
534
|
+
isDark={isDark}
|
|
535
|
+
/>
|
|
536
|
+
</SettingRow>
|
|
537
|
+
<SettingRow label="Marketing and promotions" isDark={isDark}>
|
|
538
|
+
<Toggle
|
|
539
|
+
enabled={preferences.notifications?.email_marketing ?? false}
|
|
540
|
+
onChange={(value) => updatePreference('notifications', 'email_marketing', value)}
|
|
541
|
+
isDark={isDark}
|
|
542
|
+
/>
|
|
543
|
+
</SettingRow>
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{/* SMS Notifications */}
|
|
548
|
+
<SettingRow
|
|
549
|
+
label="SMS Notifications"
|
|
550
|
+
description="Receive urgent alerts via text message"
|
|
551
|
+
isDark={isDark}
|
|
552
|
+
>
|
|
553
|
+
<Toggle
|
|
554
|
+
enabled={smsEnabled}
|
|
555
|
+
onChange={(value) => updatePreference('notifications', 'sms_enabled', value)}
|
|
556
|
+
isDark={isDark}
|
|
557
|
+
/>
|
|
558
|
+
</SettingRow>
|
|
559
|
+
|
|
560
|
+
{smsEnabled && (
|
|
561
|
+
<div className={`ml-6 border-l-2 ${borderColor} pl-4`}>
|
|
562
|
+
<SettingRow label="Security alerts only" isDark={isDark}>
|
|
563
|
+
<Toggle
|
|
564
|
+
enabled={preferences.notifications?.sms_security_alerts ?? true}
|
|
565
|
+
onChange={(value) => updatePreference('notifications', 'sms_security_alerts', value)}
|
|
566
|
+
isDark={isDark}
|
|
567
|
+
/>
|
|
568
|
+
</SettingRow>
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
</SettingsSection>
|
|
572
|
+
|
|
573
|
+
{/* Privacy Section */}
|
|
574
|
+
<SettingsSection title="Privacy" isDark={isDark}>
|
|
575
|
+
{/* Profile Visibility */}
|
|
576
|
+
<div className="mb-4">
|
|
577
|
+
<label className={`block text-sm font-medium ${textMuted} mb-2`}>Profile Visibility</label>
|
|
578
|
+
<div className="space-y-2">
|
|
579
|
+
{([
|
|
580
|
+
{ value: 'private', label: 'Private', description: 'Only you can see your profile' },
|
|
581
|
+
{ value: 'team', label: 'Team', description: 'Team members can see your profile' },
|
|
582
|
+
{ value: 'public', label: 'Public', description: 'Anyone can see your profile' },
|
|
583
|
+
] as const).map((option) => (
|
|
584
|
+
<label
|
|
585
|
+
key={option.value}
|
|
586
|
+
className={`flex items-center p-3 rounded-md cursor-pointer transition-colors ${
|
|
587
|
+
profileVisibility === option.value
|
|
588
|
+
? isDark
|
|
589
|
+
? 'bg-blue-600/20 border border-blue-500'
|
|
590
|
+
: 'bg-blue-50 border border-blue-500'
|
|
591
|
+
: isDark
|
|
592
|
+
? 'bg-slate-700 hover:bg-slate-600'
|
|
593
|
+
: 'bg-gray-50 hover:bg-gray-100'
|
|
594
|
+
}`}
|
|
595
|
+
>
|
|
596
|
+
<input
|
|
597
|
+
type="radio"
|
|
598
|
+
name="profileVisibility"
|
|
599
|
+
value={option.value}
|
|
600
|
+
checked={profileVisibility === option.value}
|
|
601
|
+
onChange={(e) =>
|
|
602
|
+
updatePreference('privacy', 'profile_visibility', e.target.value as 'private' | 'team' | 'public')
|
|
603
|
+
}
|
|
604
|
+
className="mr-3"
|
|
605
|
+
/>
|
|
606
|
+
<div>
|
|
607
|
+
<p className={`font-medium ${textPrimary}`}>{option.label}</p>
|
|
608
|
+
<p className={`text-sm ${textMuted}`}>{option.description}</p>
|
|
609
|
+
</div>
|
|
610
|
+
</label>
|
|
611
|
+
))}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
|
|
615
|
+
{/* Activity Status */}
|
|
616
|
+
<SettingRow
|
|
617
|
+
label="Activity Status"
|
|
618
|
+
description="Show when you're active to team members"
|
|
619
|
+
isDark={isDark}
|
|
620
|
+
>
|
|
621
|
+
<Toggle
|
|
622
|
+
enabled={showActivityStatus}
|
|
623
|
+
onChange={(value) => updatePreference('privacy', 'show_activity_status', value)}
|
|
624
|
+
isDark={isDark}
|
|
625
|
+
/>
|
|
626
|
+
</SettingRow>
|
|
627
|
+
</SettingsSection>
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
{/* Navigation Links */}
|
|
631
|
+
<div className="mt-6 flex justify-between">
|
|
632
|
+
<a href="/account/security" className={`text-sm hover:underline ${textMuted}`}>
|
|
633
|
+
← Security Settings
|
|
634
|
+
</a>
|
|
635
|
+
<a href="/account/profile" className={`text-sm hover:underline ${textMuted}`}>
|
|
636
|
+
Back to Profile →
|
|
637
|
+
</a>
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
);
|
|
642
|
+
}
|