@djangocfg/layouts 2.1.263 → 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 CHANGED
@@ -124,10 +124,49 @@ import { AppLayout } from '@djangocfg/layouts';
124
124
  | **PrivateLayout** | App shell — sidebar + header |
125
125
  | **AuthLayout** | Sign-in flows |
126
126
  | **AdminLayout** | Admin console |
127
- | **ProfileLayout** | Profile + 2FA |
127
+ | **ProfileLayout** | Profile page with avatar, editable fields, 2FA, and slot/tab system |
128
128
 
129
129
  **Brand:** `ThemeBrandMark` / **`ThemeBrandMarkImg`** for logo slots.
130
130
 
131
+ ### ProfileLayout — slots & tabs
132
+
133
+ ```tsx
134
+ import { ProfileLayout } from '@djangocfg/layouts';
135
+ import type { ProfileTab, ProfileSlots } from '@djangocfg/layouts';
136
+
137
+ const tabs: ProfileTab[] = [
138
+ {
139
+ value: 'billing',
140
+ label: 'Billing',
141
+ content: <BillingSection />,
142
+ },
143
+ ];
144
+
145
+ const slots: ProfileSlots = {
146
+ headerBadge: <Badge>Pro</Badge>, // next to user name
147
+ headerMenuItems: <DropdownMenuItem>…</DropdownMenuItem>, // in ⋯ menu
148
+ headerAfter: <OnboardingBanner />, // below avatar, above tabs
149
+ footer: <LinkedAccounts />, // below all tab content
150
+ };
151
+
152
+ <ProfileLayout
153
+ enable2FA
154
+ enableDeleteAccount
155
+ tabs={tabs}
156
+ slots={slots}
157
+ />
158
+ ```
159
+
160
+ | Prop | Type | Description |
161
+ |------|------|-------------|
162
+ | `enable2FA` | `boolean` | Show Security tab with 2FA management |
163
+ | `enableDeleteAccount` | `boolean` | Show Delete account in `⋯` menu |
164
+ | `tabs` | `ProfileTab[]` | Extra tabs appended after built-in ones |
165
+ | `slots.headerBadge` | `ReactNode` | Rendered next to the user name (plan, role…) |
166
+ | `slots.headerMenuItems` | `ReactNode` | Extra `DropdownMenuItem`s in the `⋯` menu |
167
+ | `slots.headerAfter` | `ReactNode` | Below avatar row, above tabs |
168
+ | `slots.footer` | `ReactNode` | Below all tab content |
169
+
131
170
  ---
132
171
 
133
172
  ## i18n on AppLayout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.263",
3
+ "version": "2.1.266",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.263",
78
- "@djangocfg/centrifugo": "^2.1.263",
79
- "@djangocfg/i18n": "^2.1.263",
80
- "@djangocfg/monitor": "^2.1.263",
81
- "@djangocfg/debuger": "^2.1.263",
82
- "@djangocfg/ui-core": "^2.1.263",
83
- "@djangocfg/ui-nextjs": "^2.1.263",
84
- "@djangocfg/ui-tools": "^2.1.263",
77
+ "@djangocfg/api": "^2.1.266",
78
+ "@djangocfg/centrifugo": "^2.1.266",
79
+ "@djangocfg/i18n": "^2.1.266",
80
+ "@djangocfg/monitor": "^2.1.266",
81
+ "@djangocfg/debuger": "^2.1.266",
82
+ "@djangocfg/ui-core": "^2.1.266",
83
+ "@djangocfg/ui-nextjs": "^2.1.266",
84
+ "@djangocfg/ui-tools": "^2.1.266",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -103,21 +103,22 @@
103
103
  }
104
104
  },
105
105
  "dependencies": {
106
+ "libphonenumber-js": "^1.12.24",
106
107
  "nextjs-toploader": "^3.9.17",
107
108
  "qrcode.react": "^4.2.0",
108
109
  "react-ga4": "^2.1.0",
109
110
  "uuid": "^11.1.0"
110
111
  },
111
112
  "devDependencies": {
112
- "@djangocfg/api": "^2.1.263",
113
- "@djangocfg/i18n": "^2.1.263",
114
- "@djangocfg/centrifugo": "^2.1.263",
115
- "@djangocfg/monitor": "^2.1.263",
116
- "@djangocfg/debuger": "^2.1.263",
117
- "@djangocfg/typescript-config": "^2.1.263",
118
- "@djangocfg/ui-core": "^2.1.263",
119
- "@djangocfg/ui-nextjs": "^2.1.263",
120
- "@djangocfg/ui-tools": "^2.1.263",
113
+ "@djangocfg/api": "^2.1.266",
114
+ "@djangocfg/i18n": "^2.1.266",
115
+ "@djangocfg/centrifugo": "^2.1.266",
116
+ "@djangocfg/monitor": "^2.1.266",
117
+ "@djangocfg/debuger": "^2.1.266",
118
+ "@djangocfg/typescript-config": "^2.1.266",
119
+ "@djangocfg/ui-core": "^2.1.266",
120
+ "@djangocfg/ui-nextjs": "^2.1.266",
121
+ "@djangocfg/ui-tools": "^2.1.266",
121
122
  "@types/node": "^24.7.2",
122
123
  "@types/react": "^19.1.0",
123
124
  "@types/react-dom": "^19.1.0",
@@ -15,12 +15,8 @@ export interface SetupStepProps {
15
15
  }
16
16
 
17
17
  /**
18
- * SetupStep - Orchestrator for 2FA setup flow
19
- *
20
- * Delegates rendering to focused sub-components:
21
- * - SetupLoading: Initial loading state
22
- * - SetupQRCode: QR code scanning
23
- * - SetupComplete: Backup codes display
18
+ * SetupStep - Orchestrator for 2FA setup flow (requires AuthFormContext).
19
+ * For use outside AuthLayout use SetupStepStandalone.
24
20
  */
25
21
  export const SetupStep: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
26
22
  const { setStep } = useAuthFormContext();
@@ -89,3 +85,51 @@ export const SetupStep: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
89
85
  // Fallback to loading
90
86
  return <SetupLoading />;
91
87
  };
88
+
89
+ /**
90
+ * SetupStepStandalone — same flow as SetupStep but without AuthFormContext.
91
+ * Use this inside ProfileLayout or any page outside AuthLayout.
92
+ */
93
+ export const SetupStepStandalone: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
94
+ const {
95
+ isLoading,
96
+ error,
97
+ setupData,
98
+ backupCodes,
99
+ backupCodesWarning,
100
+ setupStep,
101
+ startSetup,
102
+ confirmSetup,
103
+ } = useTwoFactorSetup({ onComplete, onError: () => {} });
104
+
105
+ React.useEffect(() => {
106
+ if (setupStep === 'idle') startSetup();
107
+ }, [setupStep, startSetup]);
108
+
109
+ if (isLoading && !setupData) return <SetupLoading />;
110
+
111
+ if (setupStep === 'complete' && backupCodes) {
112
+ return (
113
+ <SetupComplete
114
+ backupCodes={backupCodes}
115
+ backupCodesWarning={backupCodesWarning}
116
+ onDone={() => onComplete?.()}
117
+ />
118
+ );
119
+ }
120
+
121
+ if (setupData) {
122
+ return (
123
+ <SetupQRCode
124
+ provisioningUri={setupData.provisioningUri}
125
+ secret={setupData.secret}
126
+ isLoading={isLoading}
127
+ error={error}
128
+ onConfirm={confirmSetup}
129
+ onSkip={onSkip}
130
+ />
131
+ );
132
+ }
133
+
134
+ return <SetupLoading />;
135
+ };
@@ -1,26 +1,27 @@
1
1
  'use client';
2
2
 
3
- import { Camera, LogOut } from 'lucide-react';
3
+ import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
4
4
  import moment from 'moment';
5
- import React, { useEffect, useMemo, useState } from 'react';
6
-
7
- import { useAppT } from '@djangocfg/i18n';
8
- import { toast } 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
- Avatar,
13
- AvatarFallback,
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
- ActionButton,
23
+ AvatarSection,
22
24
  DeleteAccountScreen,
23
- DeleteAccountSection,
24
25
  EditableField,
25
26
  Section,
26
27
  TwoFactorSection,
@@ -28,100 +29,172 @@ import {
28
29
  import { ProfileProvider, useProfileContext } from './context';
29
30
 
30
31
  // ─────────────────────────────────────────────────────────────────────────────
31
- // Types
32
+ // Slot + Tab types (public API)
32
33
  // ─────────────────────────────────────────────────────────────────────────────
33
34
 
34
- interface ProfileLayoutProps {
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;
64
+ }
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // Header
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+
70
+ function ProfileHeader({ slots, enableDeleteAccount }: {
71
+ slots?: ProfileSlots;
72
+ enableDeleteAccount?: boolean;
73
+ }) {
74
+ const { labels, onLogout, setStep } = useProfileContext();
75
+ const { user } = useAuth();
76
+
77
+ if (!user) return null;
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;
83
+
84
+ const badge = slots?.headerBadge ?? null;
85
+ const menuItems = slots?.headerMenuItems ?? null;
86
+ const headerAfter = slots?.headerAfter ?? null;
87
+
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>
103
+ )}
104
+ </div>
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>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // ─────────────────────────────────────────────────────────────────────────────
167
+ // Built-in tab: Security
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+
170
+ function TabSecurity() {
171
+ return (
172
+ <div className="pt-4 space-y-4">
173
+ <TwoFactorSection />
174
+ </div>
175
+ );
39
176
  }
40
177
 
41
178
  // ─────────────────────────────────────────────────────────────────────────────
42
- // Profile Content
179
+ // Main content
43
180
  // ─────────────────────────────────────────────────────────────────────────────
44
181
 
45
- const ProfileContent = ({
182
+ function ProfileContent({
46
183
  onUnauthenticated,
47
- title,
48
- enable2FA = false,
184
+ enable2FA,
49
185
  enableDeleteAccount = true,
50
- }: ProfileLayoutProps) => {
51
- const { user, isLoading, uploadAvatar, updateProfile } = useAuth();
52
- const [isUploading, setIsUploading] = useState(false);
53
- const t = useAppT();
54
-
55
- const labels = useMemo(() => ({
56
- title: title || t('layouts.profilePage.title'),
57
- personalInfo: t('layouts.profilePage.personalInfo'),
58
- work: t('layouts.profilePage.work'),
59
- security: t('layouts.profilePage.security'),
60
- firstName: t('layouts.profilePage.firstName'),
61
- lastName: t('layouts.profilePage.lastName'),
62
- email: t('layouts.profilePage.email'),
63
- phone: t('layouts.profilePage.phone'),
64
- company: t('layouts.profilePage.company'),
65
- position: t('layouts.profilePage.position'),
66
- notSet: t('layouts.profilePage.notSet'),
67
- addFirstName: t('layouts.profilePage.addFirstName') || 'Add first name',
68
- addLastName: t('layouts.profilePage.addLastName') || 'Add last name',
69
- addPhone: t('layouts.profilePage.addPhone') || 'Add phone number',
70
- addCompany: t('layouts.profilePage.addCompany') || 'Add company',
71
- addPosition: t('layouts.profilePage.addPosition') || 'Add position',
72
- signOut: t('layouts.profilePage.signOut'),
73
- changeAvatar: t('layouts.profilePage.changeAvatar'),
74
- memberSince: t('layouts.profilePage.memberSince'),
75
- profileUpdated: t('layouts.profilePage.profileUpdated'),
76
- avatarUpdated: t('layouts.profilePage.avatarUpdated'),
77
- failedToUpdate: t('layouts.profilePage.failedToUpdate'),
78
- failedToUploadAvatar: t('layouts.profilePage.failedToUploadAvatar'),
79
- selectImageFile: t('layouts.profilePage.selectImageFile'),
80
- fileTooLarge: t('layouts.profilePage.fileTooLarge'),
81
- notAuthenticated: t('layouts.profilePage.notAuthenticated'),
82
- pleaseLogIn: t('layouts.profilePage.pleaseLogIn'),
83
- loading: t('ui.states.loading'),
84
- }), [t, title]);
186
+ tabs = [],
187
+ slots,
188
+ }: ProfileLayoutProps) {
189
+ const { labels } = useProfileContext();
190
+ const { user, isLoading } = useAuth();
85
191
 
86
192
  useEffect(() => {
87
193
  if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
88
194
  }, [onUnauthenticated, user, isLoading]);
89
195
 
90
- const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
91
- const file = event.target.files?.[0];
92
- if (!file) return;
93
- if (!file.type.startsWith('image/')) { toast.error(labels.selectImageFile); return; }
94
- if (file.size > 5 * 1024 * 1024) { toast.error(labels.fileTooLarge); return; }
95
-
96
- setIsUploading(true);
97
- try {
98
- await uploadAvatar(file);
99
- toast.success(labels.avatarUpdated);
100
- } catch (error) {
101
- toast.error(labels.failedToUploadAvatar);
102
- profileLogger.error('Avatar upload error:', error);
103
- } finally {
104
- setIsUploading(false);
105
- }
106
- };
107
-
108
- const handleFieldSave = async (field: string, value: string) => {
109
- try {
110
- await updateProfile({ [field]: value });
111
- toast.success(labels.profileUpdated);
112
- } catch (error: any) {
113
- profileLogger.error('Profile update error:', error);
114
- toast.error(error?.response?.data?.[field]?.[0] || labels.failedToUpdate);
115
- throw error;
116
- }
117
- };
118
-
119
- const handleLogout = useLogout();
120
-
121
196
  if (isLoading) {
122
- return (
123
- <Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />
124
- );
197
+ return <Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />;
125
198
  }
126
199
 
127
200
  if (!user) {
@@ -135,131 +208,66 @@ const ProfileContent = ({
135
208
  );
136
209
  }
137
210
 
138
- const getInitials = (name: string) =>
139
- name ? name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : 'U';
211
+ // ── Prepare data before render ──────────────────────────────────────────────
140
212
 
141
- const displayName = user.full_name || user.display_username || user.email;
142
- const memberSinceText = useMemo(
143
- () => user.date_joined
144
- ? labels.memberSince.replace('{date}', moment.utc(user.date_joined).local().format('MMMM YYYY'))
145
- : null,
146
- [user.date_joined, labels.memberSince]
147
- );
213
+ const extraTriggers = tabs.map((tab) => (
214
+ <TabsTrigger key={tab.value} value={tab.value}>
215
+ {tab.label}
216
+ </TabsTrigger>
217
+ ));
148
218
 
149
- return (
150
- <div className="container mx-auto px-4 py-12 max-w-md">
151
- {/* Avatar + header */}
152
- <div className="flex flex-col items-center mb-12">
153
- <div className="relative group mb-4">
154
- <Avatar className="w-28 h-28 text-3xl">
155
- {user.avatar ? (
156
- <img src={user.avatar} alt="" className="w-full h-full object-cover" />
157
- ) : (
158
- <AvatarFallback className="bg-muted text-muted-foreground text-2xl font-medium">
159
- {getInitials(user.display_username || user.email || '')}
160
- </AvatarFallback>
161
- )}
162
- </Avatar>
163
- <label
164
- className={cn(
165
- 'absolute inset-0 rounded-full flex items-center justify-center cursor-pointer',
166
- 'bg-black/0 group-hover:bg-black/40 transition-colors'
167
- )}
168
- title={labels.changeAvatar}
169
- >
170
- <div className="opacity-0 group-hover:opacity-100 transition-opacity">
171
- {isUploading ? (
172
- <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
173
- ) : (
174
- <Camera className="w-6 h-6 text-white" />
175
- )}
176
- </div>
177
- <input
178
- type="file"
179
- accept="image/*"
180
- onChange={handleAvatarChange}
181
- className="hidden"
182
- disabled={isUploading}
183
- />
184
- </label>
185
- </div>
219
+ const extraPanels = tabs.map((tab) => (
220
+ <TabsContent key={tab.value} value={tab.value}>
221
+ {tab.content}
222
+ </TabsContent>
223
+ ));
186
224
 
187
- <h1 className="text-2xl font-semibold tracking-tight">{displayName}</h1>
188
- <p className="text-[15px] text-muted-foreground mt-1">{user.email}</p>
189
- {memberSinceText && (
190
- <p className="text-[13px] text-muted-foreground/60 mt-2">{memberSinceText}</p>
191
- )}
192
- </div>
225
+ const footer = slots?.footer ?? null;
193
226
 
194
- {/* Personal Info */}
195
- <Section title={labels.personalInfo}>
196
- <EditableField
197
- label={labels.firstName}
198
- value={user.first_name || ''}
199
- placeholder={labels.addFirstName}
200
- onSave={(v) => handleFieldSave('first_name', v)}
201
- />
202
- <EditableField
203
- label={labels.lastName}
204
- value={user.last_name || ''}
205
- placeholder={labels.addLastName}
206
- onSave={(v) => handleFieldSave('last_name', v)}
207
- />
208
- <EditableField
209
- label={labels.phone}
210
- value={user.phone || ''}
211
- placeholder={labels.addPhone}
212
- onSave={(v) => handleFieldSave('phone', v)}
213
- type="phone"
214
- />
215
- </Section>
227
+ // ── Render ──────────────────────────────────────────────────────────────────
216
228
 
217
- {/* Work */}
218
- <Section title={labels.work}>
219
- <EditableField
220
- label={labels.company}
221
- value={user.company || ''}
222
- placeholder={labels.addCompany}
223
- onSave={(v) => handleFieldSave('company', v)}
224
- />
225
- <EditableField
226
- label={labels.position}
227
- value={user.position || ''}
228
- placeholder={labels.addPosition}
229
- onSave={(v) => handleFieldSave('position', v)}
230
- />
231
- </Section>
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
232
 
233
- {/* Security */}
234
- {enable2FA && <TwoFactorSection />}
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>
235
240
 
236
- {/* Actions */}
237
- <Section>
238
- <ActionButton
239
- icon={<LogOut className="w-4 h-4" />}
240
- label={labels.signOut}
241
- onClick={handleLogout}
242
- />
243
- </Section>
241
+ <TabsContent value="profile">
242
+ <TabProfile />
243
+ </TabsContent>
244
+
245
+ {enable2FA && (
246
+ <TabsContent value="security">
247
+ <TabSecurity />
248
+ </TabsContent>
249
+ )}
244
250
 
245
- {enableDeleteAccount && <DeleteAccountSection />}
251
+ {extraPanels}
252
+ </Tabs>
253
+
254
+ {footer && <div className="mt-8">{footer}</div>}
246
255
  </div>
247
256
  );
248
- };
257
+ }
249
258
 
250
259
  // ─────────────────────────────────────────────────────────────────────────────
251
- // Export
260
+ // Router + Export
252
261
  // ─────────────────────────────────────────────────────────────────────────────
253
262
 
254
- const ProfileRouter: React.FC<ProfileLayoutProps> = (props) => {
263
+ function ProfileRouter(props: ProfileLayoutProps) {
255
264
  const { step } = useProfileContext();
256
-
257
265
  if (step === 'delete-account') return <DeleteAccountScreen />;
258
266
  return <ProfileContent {...props} />;
259
- };
267
+ }
260
268
 
261
- export const ProfileLayout: React.FC<ProfileLayoutProps> = (props) => (
262
- <ProfileProvider>
263
- <ProfileRouter {...props} />
269
+ export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
270
+ <ProfileProvider title={title}>
271
+ <ProfileRouter title={title} {...props} />
264
272
  </ProfileProvider>
265
273
  );
@@ -72,11 +72,10 @@ export const AvatarSection = () => {
72
72
  };
73
73
 
74
74
  return (
75
- <div className="flex flex-col items-center mb-4">
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