@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 +40 -1
- package/package.json +19 -18
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +50 -6
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +203 -195
- 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
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
|
|
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.
|
|
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.
|
|
78
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
80
|
-
"@djangocfg/monitor": "^2.1.
|
|
81
|
-
"@djangocfg/debuger": "^2.1.
|
|
82
|
-
"@djangocfg/ui-core": "^2.1.
|
|
83
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
84
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
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.
|
|
113
|
-
"@djangocfg/i18n": "^2.1.
|
|
114
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
115
|
-
"@djangocfg/monitor": "^2.1.
|
|
116
|
-
"@djangocfg/debuger": "^2.1.
|
|
117
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
118
|
-
"@djangocfg/ui-core": "^2.1.
|
|
119
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
120
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
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 {
|
|
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 } 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,100 +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;
|
|
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
|
-
//
|
|
179
|
+
// Main content
|
|
43
180
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
181
|
|
|
45
|
-
|
|
182
|
+
function ProfileContent({
|
|
46
183
|
onUnauthenticated,
|
|
47
|
-
|
|
48
|
-
enable2FA = false,
|
|
184
|
+
enable2FA,
|
|
49
185
|
enableDeleteAccount = true,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
139
|
-
name ? name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : 'U';
|
|
211
|
+
// ── Prepare data before render ──────────────────────────────────────────────
|
|
140
212
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
<
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
<TabsContent value="profile">
|
|
242
|
+
<TabProfile />
|
|
243
|
+
</TabsContent>
|
|
244
|
+
|
|
245
|
+
{enable2FA && (
|
|
246
|
+
<TabsContent value="security">
|
|
247
|
+
<TabSecurity />
|
|
248
|
+
</TabsContent>
|
|
249
|
+
)}
|
|
244
250
|
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
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
|