@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.
@@ -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, 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
- 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,174 +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;
39
64
  }
40
65
 
41
66
  // ─────────────────────────────────────────────────────────────────────────────
42
- // Avatar with image check
67
+ // Header
43
68
  // ─────────────────────────────────────────────────────────────────────────────
44
69
 
45
- function ProfileAvatar({
46
- src,
47
- initials,
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 { isLoading, isLoaded } = useImageLoader(src ?? undefined);
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
- {/* Image show only when loaded successfully */}
69
- {src && (
70
- <img
71
- src={src}
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
- {/* Fallback show when no src or image failed */}
81
- {(!src || (!isLoading && !isLoaded)) && (
82
- <AvatarFallback className="bg-muted text-muted-foreground text-2xl font-medium">
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
- {/* Upload overlay */}
89
- <label
90
- className={cn(
91
- 'absolute inset-0 rounded-full flex items-center justify-center cursor-pointer',
92
- 'bg-black/0 group-hover:bg-black/40 transition-colors',
93
- )}
94
- title={label}
95
- >
96
- <div className="opacity-0 group-hover:opacity-100 transition-opacity">
97
- {isUploading ? (
98
- <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
99
- ) : (
100
- <Camera className="w-6 h-6 text-white drop-shadow" />
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
- <input
104
- type="file"
105
- accept="image/*"
106
- onChange={onChange}
107
- className="hidden"
108
- disabled={isUploading}
109
- />
110
- </label>
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
- // Profile Content
167
+ // Built-in tab: Security
117
168
  // ─────────────────────────────────────────────────────────────────────────────
118
169
 
119
- const ProfileContent = ({
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
- title,
122
- enable2FA = false,
184
+ enable2FA,
123
185
  enableDeleteAccount = true,
124
- }: ProfileLayoutProps) => {
125
- const { user, isLoading, uploadAvatar, updateProfile } = useAuth();
126
- const [isUploading, setIsUploading] = useState(false);
127
- const t = useAppT();
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
- const getInitials = (name: string) =>
213
- name ? name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : 'U';
211
+ // ── Prepare data before render ──────────────────────────────────────────────
214
212
 
215
- const displayName = user.full_name || user.display_username || user.email;
216
- const memberSinceText = useMemo(
217
- () => user.date_joined
218
- ? labels.memberSince.replace('{date}', moment.utc(user.date_joined).local().format('MMMM YYYY'))
219
- : null,
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
- return (
224
- <div className="container mx-auto px-4 py-12 max-w-md">
225
- {/* Avatar + header */}
226
- <div className="flex flex-col items-center mb-12">
227
- <ProfileAvatar
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
- {/* Personal Info */}
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
- {/* Work */}
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
- {/* Security */}
282
- {enable2FA && <TwoFactorSection />}
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
- {/* Actions */}
285
- <Section>
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
- {enableDeleteAccount && <DeleteAccountSection />}
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
- const ProfileRouter: React.FC<ProfileLayoutProps> = (props) => {
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 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
@@ -1,11 +1,22 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useMemo, useState } from 'react';
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 t = useAppT();
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 || placeholder}
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-10', className)}>
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}