@djangocfg/layouts 2.1.264 → 2.1.266
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 +40 -1
- package/package.json +19 -18
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +50 -6
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +190 -230
- package/src/layouts/ProfileLayout/components/AvatarSection.tsx +2 -3
- package/src/layouts/ProfileLayout/components/EditableField.tsx +15 -10
- package/src/layouts/ProfileLayout/components/Section.tsx +1 -1
- package/src/layouts/ProfileLayout/components/TwoFactorSection.tsx +255 -215
- package/src/layouts/ProfileLayout/context.tsx +110 -10
- package/src/layouts/ProfileLayout/index.ts +1 -1
- package/src/layouts/ProfileLayout/.claude/.sidecar/activity.jsonl +0 -2
- package/src/layouts/ProfileLayout/.claude/.sidecar/history/2026-03-15.md +0 -35
- package/src/layouts/ProfileLayout/.claude/.sidecar/review.md +0 -35
- package/src/layouts/ProfileLayout/.claude/.sidecar/scan.log +0 -3
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-001.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-002.md +0 -19
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-003.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-004.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-005.md +0 -18
- package/src/layouts/ProfileLayout/.claude/.sidecar/usage.json +0 -5
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
4
4
|
import moment from 'moment';
|
|
5
|
-
import React, { useEffect
|
|
6
|
-
|
|
7
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
-
import { toast, useImageLoader } from '@djangocfg/ui-core/hooks';
|
|
5
|
+
import React, { useEffect } from 'react';
|
|
9
6
|
|
|
10
7
|
import { useAuth } from '@djangocfg/api/auth';
|
|
11
8
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Button,
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
14
15
|
Preloader,
|
|
16
|
+
Tabs,
|
|
17
|
+
TabsContent,
|
|
18
|
+
TabsList,
|
|
19
|
+
TabsTrigger,
|
|
15
20
|
} from '@djangocfg/ui-core/components';
|
|
16
|
-
import { cn } from '@djangocfg/ui-core/lib';
|
|
17
21
|
|
|
18
|
-
import { profileLogger } from '../../utils/logger';
|
|
19
|
-
import { useLogout } from '../../hooks';
|
|
20
22
|
import {
|
|
21
|
-
|
|
23
|
+
AvatarSection,
|
|
22
24
|
DeleteAccountScreen,
|
|
23
|
-
DeleteAccountSection,
|
|
24
25
|
EditableField,
|
|
25
26
|
Section,
|
|
26
27
|
TwoFactorSection,
|
|
@@ -28,174 +29,172 @@ import {
|
|
|
28
29
|
import { ProfileProvider, useProfileContext } from './context';
|
|
29
30
|
|
|
30
31
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
-
//
|
|
32
|
+
// Slot + Tab types (public API)
|
|
32
33
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
34
|
|
|
34
|
-
interface
|
|
35
|
+
export interface ProfileTab {
|
|
36
|
+
/** Unique key, used as Tabs value */
|
|
37
|
+
value: string;
|
|
38
|
+
/** Trigger label */
|
|
39
|
+
label: React.ReactNode;
|
|
40
|
+
/** Tab panel content */
|
|
41
|
+
content: React.ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProfileSlots {
|
|
45
|
+
/** Extra items rendered inside the ⋯ dropdown, above the separator before Delete */
|
|
46
|
+
headerMenuItems?: React.ReactNode;
|
|
47
|
+
/** Rendered next to the user name (e.g. plan badge, role chip) */
|
|
48
|
+
headerBadge?: React.ReactNode;
|
|
49
|
+
/** Rendered below the avatar row, above the tabs */
|
|
50
|
+
headerAfter?: React.ReactNode;
|
|
51
|
+
/** Rendered below all tab content */
|
|
52
|
+
footer?: React.ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProfileLayoutProps {
|
|
35
56
|
onUnauthenticated?: () => void;
|
|
36
57
|
title?: string;
|
|
37
58
|
enable2FA?: boolean;
|
|
38
59
|
enableDeleteAccount?: boolean;
|
|
60
|
+
/** Extra tabs appended after built-in Profile / Security tabs */
|
|
61
|
+
tabs?: ProfileTab[];
|
|
62
|
+
/** Named slots for additional content */
|
|
63
|
+
slots?: ProfileSlots;
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
-
//
|
|
67
|
+
// Header
|
|
43
68
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
69
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
isUploading,
|
|
49
|
-
onChange,
|
|
50
|
-
label,
|
|
51
|
-
}: {
|
|
52
|
-
src?: string | null;
|
|
53
|
-
initials: string;
|
|
54
|
-
isUploading: boolean;
|
|
55
|
-
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
56
|
-
label: string;
|
|
70
|
+
function ProfileHeader({ slots, enableDeleteAccount }: {
|
|
71
|
+
slots?: ProfileSlots;
|
|
72
|
+
enableDeleteAccount?: boolean;
|
|
57
73
|
}) {
|
|
58
|
-
const {
|
|
74
|
+
const { labels, onLogout, setStep } = useProfileContext();
|
|
75
|
+
const { user } = useAuth();
|
|
59
76
|
|
|
60
|
-
return
|
|
61
|
-
<div className="relative group mb-4">
|
|
62
|
-
<Avatar className="w-28 h-28 text-3xl">
|
|
63
|
-
{/* Skeleton while image is loading */}
|
|
64
|
-
{src && isLoading && (
|
|
65
|
-
<div className="w-full h-full rounded-full bg-muted animate-pulse" />
|
|
66
|
-
)}
|
|
77
|
+
if (!user) return null;
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
alt=""
|
|
73
|
-
className={cn(
|
|
74
|
-
'w-full h-full object-cover rounded-full transition-opacity duration-300',
|
|
75
|
-
isLoaded ? 'opacity-100' : 'opacity-0 absolute inset-0',
|
|
76
|
-
)}
|
|
77
|
-
/>
|
|
78
|
-
)}
|
|
79
|
+
const displayName = user.full_name || user.display_username || user.email;
|
|
80
|
+
const memberSince = user.date_joined
|
|
81
|
+
? moment.utc(user.date_joined).local().format('MMMM YYYY')
|
|
82
|
+
: null;
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{initials}
|
|
84
|
-
</AvatarFallback>
|
|
85
|
-
)}
|
|
86
|
-
</Avatar>
|
|
84
|
+
const badge = slots?.headerBadge ?? null;
|
|
85
|
+
const menuItems = slots?.headerMenuItems ?? null;
|
|
86
|
+
const headerAfter = slots?.headerAfter ?? null;
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
88
|
+
return (
|
|
89
|
+
<div className="pb-4 md:pb-6 border-b mb-2">
|
|
90
|
+
<div className="flex items-center gap-3 md:gap-4">
|
|
91
|
+
<AvatarSection />
|
|
92
|
+
|
|
93
|
+
<div className="flex-1 min-w-0">
|
|
94
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
95
|
+
<h1 className="text-lg md:text-xl font-semibold truncate">{displayName}</h1>
|
|
96
|
+
{badge}
|
|
97
|
+
</div>
|
|
98
|
+
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
|
99
|
+
{memberSince && (
|
|
100
|
+
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
|
101
|
+
Member since {memberSince}
|
|
102
|
+
</p>
|
|
101
103
|
)}
|
|
102
104
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
|
|
106
|
+
<DropdownMenu>
|
|
107
|
+
<DropdownMenuTrigger asChild>
|
|
108
|
+
<Button variant="ghost" size="icon" className="flex-shrink-0 rounded-full">
|
|
109
|
+
<MoreHorizontal className="w-4 h-4" />
|
|
110
|
+
</Button>
|
|
111
|
+
</DropdownMenuTrigger>
|
|
112
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
113
|
+
<DropdownMenuItem onClick={onLogout} className="gap-2">
|
|
114
|
+
<LogOut className="w-4 h-4" />
|
|
115
|
+
{labels.signOut}
|
|
116
|
+
</DropdownMenuItem>
|
|
117
|
+
|
|
118
|
+
{menuItems && <><DropdownMenuSeparator />{menuItems}</>}
|
|
119
|
+
|
|
120
|
+
{enableDeleteAccount && (
|
|
121
|
+
<>
|
|
122
|
+
<DropdownMenuSeparator />
|
|
123
|
+
<DropdownMenuItem
|
|
124
|
+
onClick={() => setStep('delete-account')}
|
|
125
|
+
className="gap-2 text-destructive focus:text-destructive"
|
|
126
|
+
>
|
|
127
|
+
<Trash2 className="w-4 h-4" />
|
|
128
|
+
{labels.deleteAccount}
|
|
129
|
+
</DropdownMenuItem>
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
</DropdownMenuContent>
|
|
133
|
+
</DropdownMenu>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{headerAfter && <div className="mt-4">{headerAfter}</div>}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Built-in tab: Profile
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function TabProfile() {
|
|
146
|
+
const { labels, onFieldSave } = useProfileContext();
|
|
147
|
+
const { user } = useAuth();
|
|
148
|
+
if (!user) return null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6 pt-4">
|
|
152
|
+
<Section title={labels.personalInfo}>
|
|
153
|
+
<EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
|
|
154
|
+
<EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
|
|
155
|
+
<EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
|
|
156
|
+
</Section>
|
|
157
|
+
|
|
158
|
+
<Section title={labels.work}>
|
|
159
|
+
<EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
|
|
160
|
+
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
161
|
+
</Section>
|
|
111
162
|
</div>
|
|
112
163
|
);
|
|
113
164
|
}
|
|
114
165
|
|
|
115
166
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
-
//
|
|
167
|
+
// Built-in tab: Security
|
|
117
168
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
118
169
|
|
|
119
|
-
|
|
170
|
+
function TabSecurity() {
|
|
171
|
+
return (
|
|
172
|
+
<div className="pt-4 space-y-4">
|
|
173
|
+
<TwoFactorSection />
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// Main content
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function ProfileContent({
|
|
120
183
|
onUnauthenticated,
|
|
121
|
-
|
|
122
|
-
enable2FA = false,
|
|
184
|
+
enable2FA,
|
|
123
185
|
enableDeleteAccount = true,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
const labels = useMemo(() => ({
|
|
130
|
-
title: title || t('layouts.profilePage.title'),
|
|
131
|
-
personalInfo: t('layouts.profilePage.personalInfo'),
|
|
132
|
-
work: t('layouts.profilePage.work'),
|
|
133
|
-
security: t('layouts.profilePage.security'),
|
|
134
|
-
firstName: t('layouts.profilePage.firstName'),
|
|
135
|
-
lastName: t('layouts.profilePage.lastName'),
|
|
136
|
-
email: t('layouts.profilePage.email'),
|
|
137
|
-
phone: t('layouts.profilePage.phone'),
|
|
138
|
-
company: t('layouts.profilePage.company'),
|
|
139
|
-
position: t('layouts.profilePage.position'),
|
|
140
|
-
notSet: t('layouts.profilePage.notSet'),
|
|
141
|
-
addFirstName: t('layouts.profilePage.addFirstName') || 'Add first name',
|
|
142
|
-
addLastName: t('layouts.profilePage.addLastName') || 'Add last name',
|
|
143
|
-
addPhone: t('layouts.profilePage.addPhone') || 'Add phone number',
|
|
144
|
-
addCompany: t('layouts.profilePage.addCompany') || 'Add company',
|
|
145
|
-
addPosition: t('layouts.profilePage.addPosition') || 'Add position',
|
|
146
|
-
signOut: t('layouts.profilePage.signOut'),
|
|
147
|
-
changeAvatar: t('layouts.profilePage.changeAvatar'),
|
|
148
|
-
memberSince: t('layouts.profilePage.memberSince'),
|
|
149
|
-
profileUpdated: t('layouts.profilePage.profileUpdated'),
|
|
150
|
-
avatarUpdated: t('layouts.profilePage.avatarUpdated'),
|
|
151
|
-
failedToUpdate: t('layouts.profilePage.failedToUpdate'),
|
|
152
|
-
failedToUploadAvatar: t('layouts.profilePage.failedToUploadAvatar'),
|
|
153
|
-
selectImageFile: t('layouts.profilePage.selectImageFile'),
|
|
154
|
-
fileTooLarge: t('layouts.profilePage.fileTooLarge'),
|
|
155
|
-
notAuthenticated: t('layouts.profilePage.notAuthenticated'),
|
|
156
|
-
pleaseLogIn: t('layouts.profilePage.pleaseLogIn'),
|
|
157
|
-
loading: t('ui.states.loading'),
|
|
158
|
-
}), [t, title]);
|
|
186
|
+
tabs = [],
|
|
187
|
+
slots,
|
|
188
|
+
}: ProfileLayoutProps) {
|
|
189
|
+
const { labels } = useProfileContext();
|
|
190
|
+
const { user, isLoading } = useAuth();
|
|
159
191
|
|
|
160
192
|
useEffect(() => {
|
|
161
193
|
if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
|
|
162
194
|
}, [onUnauthenticated, user, isLoading]);
|
|
163
195
|
|
|
164
|
-
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
165
|
-
const file = event.target.files?.[0];
|
|
166
|
-
if (!file) return;
|
|
167
|
-
if (!file.type.startsWith('image/')) { toast.error(labels.selectImageFile); return; }
|
|
168
|
-
if (file.size > 5 * 1024 * 1024) { toast.error(labels.fileTooLarge); return; }
|
|
169
|
-
|
|
170
|
-
setIsUploading(true);
|
|
171
|
-
try {
|
|
172
|
-
await uploadAvatar(file);
|
|
173
|
-
toast.success(labels.avatarUpdated);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
toast.error(labels.failedToUploadAvatar);
|
|
176
|
-
profileLogger.error('Avatar upload error:', error);
|
|
177
|
-
} finally {
|
|
178
|
-
setIsUploading(false);
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const handleFieldSave = async (field: string, value: string) => {
|
|
183
|
-
try {
|
|
184
|
-
await updateProfile({ [field]: value });
|
|
185
|
-
toast.success(labels.profileUpdated);
|
|
186
|
-
} catch (error: any) {
|
|
187
|
-
profileLogger.error('Profile update error:', error);
|
|
188
|
-
toast.error(error?.response?.data?.[field]?.[0] || labels.failedToUpdate);
|
|
189
|
-
throw error;
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const handleLogout = useLogout();
|
|
194
|
-
|
|
195
196
|
if (isLoading) {
|
|
196
|
-
return
|
|
197
|
-
<Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />
|
|
198
|
-
);
|
|
197
|
+
return <Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />;
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
if (!user) {
|
|
@@ -209,105 +208,66 @@ const ProfileContent = ({
|
|
|
209
208
|
);
|
|
210
209
|
}
|
|
211
210
|
|
|
212
|
-
|
|
213
|
-
name ? name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : 'U';
|
|
211
|
+
// ── Prepare data before render ──────────────────────────────────────────────
|
|
214
212
|
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
[user.date_joined, labels.memberSince]
|
|
221
|
-
);
|
|
213
|
+
const extraTriggers = tabs.map((tab) => (
|
|
214
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
215
|
+
{tab.label}
|
|
216
|
+
</TabsTrigger>
|
|
217
|
+
));
|
|
222
218
|
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
{
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
src={user.avatar}
|
|
229
|
-
initials={getInitials(user.display_username || user.email || '')}
|
|
230
|
-
isUploading={isUploading}
|
|
231
|
-
onChange={handleAvatarChange}
|
|
232
|
-
label={labels.changeAvatar}
|
|
233
|
-
/>
|
|
234
|
-
|
|
235
|
-
<h1 className="text-2xl font-semibold tracking-tight">{displayName}</h1>
|
|
236
|
-
<p className="text-[15px] text-muted-foreground mt-1">{user.email}</p>
|
|
237
|
-
{memberSinceText && (
|
|
238
|
-
<p className="text-[13px] text-muted-foreground/60 mt-2">{memberSinceText}</p>
|
|
239
|
-
)}
|
|
240
|
-
</div>
|
|
219
|
+
const extraPanels = tabs.map((tab) => (
|
|
220
|
+
<TabsContent key={tab.value} value={tab.value}>
|
|
221
|
+
{tab.content}
|
|
222
|
+
</TabsContent>
|
|
223
|
+
));
|
|
241
224
|
|
|
242
|
-
|
|
243
|
-
<Section title={labels.personalInfo}>
|
|
244
|
-
<EditableField
|
|
245
|
-
label={labels.firstName}
|
|
246
|
-
value={user.first_name || ''}
|
|
247
|
-
placeholder={labels.addFirstName}
|
|
248
|
-
onSave={(v) => handleFieldSave('first_name', v)}
|
|
249
|
-
/>
|
|
250
|
-
<EditableField
|
|
251
|
-
label={labels.lastName}
|
|
252
|
-
value={user.last_name || ''}
|
|
253
|
-
placeholder={labels.addLastName}
|
|
254
|
-
onSave={(v) => handleFieldSave('last_name', v)}
|
|
255
|
-
/>
|
|
256
|
-
<EditableField
|
|
257
|
-
label={labels.phone}
|
|
258
|
-
value={user.phone || ''}
|
|
259
|
-
placeholder={labels.addPhone}
|
|
260
|
-
onSave={(v) => handleFieldSave('phone', v)}
|
|
261
|
-
type="phone"
|
|
262
|
-
/>
|
|
263
|
-
</Section>
|
|
225
|
+
const footer = slots?.footer ?? null;
|
|
264
226
|
|
|
265
|
-
|
|
266
|
-
<Section title={labels.work}>
|
|
267
|
-
<EditableField
|
|
268
|
-
label={labels.company}
|
|
269
|
-
value={user.company || ''}
|
|
270
|
-
placeholder={labels.addCompany}
|
|
271
|
-
onSave={(v) => handleFieldSave('company', v)}
|
|
272
|
-
/>
|
|
273
|
-
<EditableField
|
|
274
|
-
label={labels.position}
|
|
275
|
-
value={user.position || ''}
|
|
276
|
-
placeholder={labels.addPosition}
|
|
277
|
-
onSave={(v) => handleFieldSave('position', v)}
|
|
278
|
-
/>
|
|
279
|
-
</Section>
|
|
227
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
280
228
|
|
|
281
|
-
|
|
282
|
-
|
|
229
|
+
return (
|
|
230
|
+
<div className="container mx-auto px-4 py-6 md:py-10 max-w-3xl">
|
|
231
|
+
<ProfileHeader slots={slots} enableDeleteAccount={enableDeleteAccount} />
|
|
232
|
+
|
|
233
|
+
<Tabs defaultValue="profile" className="mt-2">
|
|
234
|
+
{/* Underline-style scrollable tabs — mobile friendly */}
|
|
235
|
+
<TabsList variant="underline" scrollable>
|
|
236
|
+
<TabsTrigger value="profile">{labels.personalInfo}</TabsTrigger>
|
|
237
|
+
{enable2FA && <TabsTrigger value="security">{labels.security}</TabsTrigger>}
|
|
238
|
+
{extraTriggers}
|
|
239
|
+
</TabsList>
|
|
240
|
+
|
|
241
|
+
<TabsContent value="profile">
|
|
242
|
+
<TabProfile />
|
|
243
|
+
</TabsContent>
|
|
244
|
+
|
|
245
|
+
{enable2FA && (
|
|
246
|
+
<TabsContent value="security">
|
|
247
|
+
<TabSecurity />
|
|
248
|
+
</TabsContent>
|
|
249
|
+
)}
|
|
283
250
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
<ActionButton
|
|
287
|
-
icon={<LogOut className="w-4 h-4" />}
|
|
288
|
-
label={labels.signOut}
|
|
289
|
-
onClick={handleLogout}
|
|
290
|
-
/>
|
|
291
|
-
</Section>
|
|
251
|
+
{extraPanels}
|
|
252
|
+
</Tabs>
|
|
292
253
|
|
|
293
|
-
{
|
|
254
|
+
{footer && <div className="mt-8">{footer}</div>}
|
|
294
255
|
</div>
|
|
295
256
|
);
|
|
296
|
-
}
|
|
257
|
+
}
|
|
297
258
|
|
|
298
259
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
-
// Export
|
|
260
|
+
// Router + Export
|
|
300
261
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
301
262
|
|
|
302
|
-
|
|
263
|
+
function ProfileRouter(props: ProfileLayoutProps) {
|
|
303
264
|
const { step } = useProfileContext();
|
|
304
|
-
|
|
305
265
|
if (step === 'delete-account') return <DeleteAccountScreen />;
|
|
306
266
|
return <ProfileContent {...props} />;
|
|
307
|
-
}
|
|
267
|
+
}
|
|
308
268
|
|
|
309
|
-
export const ProfileLayout: React.FC<ProfileLayoutProps> = (props) => (
|
|
310
|
-
<ProfileProvider>
|
|
311
|
-
<ProfileRouter {...props} />
|
|
269
|
+
export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
|
|
270
|
+
<ProfileProvider title={title}>
|
|
271
|
+
<ProfileRouter title={title} {...props} />
|
|
312
272
|
</ProfileProvider>
|
|
313
273
|
);
|
|
@@ -72,11 +72,10 @@ export const AvatarSection = () => {
|
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
return (
|
|
75
|
-
<div className="flex flex-col items-center
|
|
75
|
+
<div className="flex flex-col items-center flex-shrink-0">
|
|
76
76
|
<div className="relative group">
|
|
77
77
|
<Avatar
|
|
78
|
-
className="aspect-square rounded-full overflow-hidden ring-1 ring-foreground/20 transition-transform group-hover:scale-105"
|
|
79
|
-
style={{ width: '80px', height: '80px' }}
|
|
78
|
+
className="w-14 h-14 md:w-20 md:h-20 aspect-square rounded-full overflow-hidden ring-1 ring-foreground/20 transition-transform group-hover:scale-105"
|
|
80
79
|
>
|
|
81
80
|
{avatarPreview ? (
|
|
82
81
|
<img
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useEffect,
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
4
5
|
|
|
5
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
6
6
|
import { Button, Input, PhoneInput } from '@djangocfg/ui-core/components';
|
|
7
7
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
8
|
|
|
9
|
+
import { useProfileContext } from '../context';
|
|
10
|
+
|
|
11
|
+
function formatPhone(raw: string): string {
|
|
12
|
+
if (!raw) return '';
|
|
13
|
+
try {
|
|
14
|
+
return parsePhoneNumberFromString(raw)?.formatInternational() ?? raw;
|
|
15
|
+
} catch {
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
interface EditableFieldProps {
|
|
10
21
|
label: string;
|
|
11
22
|
value: string;
|
|
@@ -23,17 +34,11 @@ export const EditableField = ({
|
|
|
23
34
|
disabled,
|
|
24
35
|
type = 'text',
|
|
25
36
|
}: EditableFieldProps) => {
|
|
26
|
-
const
|
|
37
|
+
const { labels } = useProfileContext();
|
|
27
38
|
const [isEditing, setIsEditing] = useState(false);
|
|
28
39
|
const [editValue, setEditValue] = useState(value);
|
|
29
40
|
const [isSaving, setIsSaving] = useState(false);
|
|
30
41
|
|
|
31
|
-
const labels = useMemo(() => ({
|
|
32
|
-
save: t('layouts.profilePage.save'),
|
|
33
|
-
saving: t('layouts.profilePage.saving'),
|
|
34
|
-
cancel: t('layouts.profilePage.cancel'),
|
|
35
|
-
}), [t]);
|
|
36
|
-
|
|
37
42
|
useEffect(() => {
|
|
38
43
|
setEditValue(value);
|
|
39
44
|
}, [value]);
|
|
@@ -112,7 +117,7 @@ export const EditableField = ({
|
|
|
112
117
|
>
|
|
113
118
|
<div className="text-[13px] text-muted-foreground mb-0.5">{label}</div>
|
|
114
119
|
<div className={cn('text-[15px]', value ? 'text-foreground' : 'text-muted-foreground/60')}>
|
|
115
|
-
{value
|
|
120
|
+
{value ? (type === 'phone' ? formatPhone(value) : value) : placeholder}
|
|
116
121
|
</div>
|
|
117
122
|
</button>
|
|
118
123
|
);
|
|
@@ -11,7 +11,7 @@ interface SectionProps {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export const Section = ({ title, children, className }: SectionProps) => (
|
|
14
|
-
<div className={cn('mb-
|
|
14
|
+
<div className={cn('mb-4 md:mb-6', className)}>
|
|
15
15
|
{title && (
|
|
16
16
|
<h2 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
|
17
17
|
{title}
|