Skip to content

Commit c992ef1

Browse files
feat(ui): add user profile account section
1 parent cbd62f4 commit c992ef1

8 files changed

Lines changed: 398 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource';
1212
const docModules: Record<string, Record<string, React.ComponentType>> = {
1313
user: {
1414
'user-button': dynamic(() => import('../stories/user-button.mdx')),
15+
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
1516
},
1617
organization: {
1718
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),

packages/swingset/src/lib/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ import {
130130
Organizations as UserButtonOrganizations,
131131
User as UserButtonUser,
132132
} from '../stories/user-button.stories';
133+
import {
134+
Default as UserProfileAccountSectionDefault,
135+
meta as userProfileAccountSectionMeta,
136+
} from '../stories/user-profile-account-section.stories';
133137
import { toSlug } from './slug';
134138
import type { StoryModule } from './types';
135139

@@ -271,9 +275,15 @@ const scrollAreaModule: StoryModule = {
271275

272276
const useDataTableModule: StoryModule = { meta: useDataTableMeta };
273277

278+
const userProfileAccountSectionModule: StoryModule = {
279+
meta: userProfileAccountSectionMeta,
280+
Default: UserProfileAccountSectionDefault,
281+
};
282+
274283
export const registry: StoryModule[] = [
275284
// User
276285
userButtonModule,
286+
userProfileAccountSectionModule,
277287
// Organization
278288
organizationProfileModule,
279289
organizationProfileGeneralPanelModule,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Stories from './user-profile-account-section.stories';
2+
3+
# UserProfileAccountSection
4+
5+
Account details, profile image, email addresses, and phone numbers composed with `Section`.
6+
7+
<Story
8+
name='Default'
9+
storyModule={Stories}
10+
composition={[
11+
{ name: 'Section', href: '/components/section', layer: 'Components' },
12+
{ name: 'Avatar', href: '/components/avatar', layer: 'Components' },
13+
]}
14+
/>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view';
3+
4+
import type { StoryMeta } from '@/lib/types';
5+
6+
export { default as __source } from './user-profile-account-section.stories?raw';
7+
8+
export const meta: StoryMeta = {
9+
group: 'User',
10+
title: 'UserProfileAccountSection',
11+
source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx',
12+
styleEngine: 'stylex',
13+
};
14+
15+
export function Default() {
16+
return (
17+
<UserProfileAccountSectionView
18+
emails={[
19+
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
20+
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
21+
]}
22+
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
23+
name='Preston Booth'
24+
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
25+
username='prestonxyz'
26+
onAddEmail={() => undefined}
27+
onAddPhone={() => undefined}
28+
onEditProfilePicture={() => undefined}
29+
onManageEmail={() => undefined}
30+
onManagePhone={() => undefined}
31+
onNameChange={() => undefined}
32+
onUsernameChange={() => undefined}
33+
/>
34+
);
35+
}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { Avatar } from '../components/avatar';
2+
import { Badge } from '../components/badge';
3+
import { Button } from '../components/button';
4+
import { Icon } from '../components/icon';
5+
import { Section } from '../components/section';
6+
import type { UserProfileMenuAction } from './user-profile-action-menu';
7+
import { UserProfileActionMenu } from './user-profile-action-menu';
8+
9+
export interface UserProfileEmail {
10+
id: string;
11+
value: string;
12+
isDefault?: boolean;
13+
isVerified?: boolean;
14+
canRemove?: boolean;
15+
}
16+
17+
export interface UserProfilePhone {
18+
id: string;
19+
value: string;
20+
isDefault?: boolean;
21+
isVerified?: boolean;
22+
canRemove?: boolean;
23+
}
24+
25+
export interface UserProfileAccountSectionViewProps {
26+
imageUrl?: string;
27+
name: string;
28+
username: string;
29+
emails: UserProfileEmail[];
30+
phones: UserProfilePhone[];
31+
onEditProfilePicture?: () => void;
32+
onNameChange?: (value: string) => void;
33+
onUsernameChange?: (value: string) => void;
34+
onAddEmail?: () => void;
35+
onManageEmail?: (id: string) => void;
36+
onVerifyEmail?: (id: string) => void;
37+
onSetPrimaryEmail?: (id: string) => void;
38+
onRemoveEmail?: (id: string) => void;
39+
onAddPhone?: () => void;
40+
onManagePhone?: (id: string) => void;
41+
onVerifyPhone?: (id: string) => void;
42+
onSetPrimaryPhone?: (id: string) => void;
43+
onRemovePhone?: (id: string) => void;
44+
}
45+
46+
export function UserProfileAccountSectionView({
47+
imageUrl,
48+
name,
49+
username,
50+
emails,
51+
phones,
52+
onEditProfilePicture,
53+
onNameChange,
54+
onUsernameChange,
55+
onAddEmail,
56+
onManageEmail,
57+
onVerifyEmail,
58+
onSetPrimaryEmail,
59+
onRemoveEmail,
60+
onAddPhone,
61+
onManagePhone,
62+
onVerifyPhone,
63+
onSetPrimaryPhone,
64+
onRemovePhone,
65+
}: UserProfileAccountSectionViewProps) {
66+
const initials = name
67+
.split(/\s+/)
68+
.map(part => part[0])
69+
.join('')
70+
.slice(0, 2)
71+
.toUpperCase();
72+
const updateName = onNameChange ? () => onNameChange(name) : undefined;
73+
const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined;
74+
75+
return (
76+
<Section.Root>
77+
<Section.Title>Account</Section.Title>
78+
<Section.Group>
79+
<Section.Row>
80+
<Section.Item>
81+
<Section.Content>
82+
<Section.Label>Profile picture</Section.Label>
83+
<Section.Description>PNG or JPEG, Recommended size 1:1, up to 10MB.</Section.Description>
84+
</Section.Content>
85+
<Section.Actions>
86+
{onEditProfilePicture ? (
87+
<Avatar.Root
88+
aria-label='Edit profile picture'
89+
render={
90+
<button
91+
type='button'
92+
onClick={onEditProfilePicture}
93+
/>
94+
}
95+
size='lg'
96+
>
97+
<Avatar.Image
98+
alt=''
99+
src={imageUrl}
100+
/>
101+
<Avatar.Fallback>{initials}</Avatar.Fallback>
102+
<Avatar.Icon>
103+
<Icon name='pen' />
104+
</Avatar.Icon>
105+
</Avatar.Root>
106+
) : (
107+
<Avatar.Root size='lg'>
108+
<Avatar.Image
109+
alt={name}
110+
src={imageUrl}
111+
/>
112+
<Avatar.Fallback>{initials}</Avatar.Fallback>
113+
</Avatar.Root>
114+
)}
115+
</Section.Actions>
116+
</Section.Item>
117+
</Section.Row>
118+
<Section.Row>
119+
<Section.Item>
120+
<Section.Content>
121+
<Section.Label>Name</Section.Label>
122+
<Section.Description>{name}</Section.Description>
123+
</Section.Content>
124+
{updateName ? (
125+
<Section.Actions>
126+
<Button
127+
color='neutral'
128+
size='sm'
129+
variant='outline'
130+
onClick={updateName}
131+
>
132+
Update name
133+
</Button>
134+
</Section.Actions>
135+
) : null}
136+
</Section.Item>
137+
</Section.Row>
138+
<Section.Row>
139+
<Section.Item>
140+
<Section.Content>
141+
<Section.Label>Username</Section.Label>
142+
<Section.Description>{username}</Section.Description>
143+
</Section.Content>
144+
{updateUsername ? (
145+
<Section.Actions>
146+
<Button
147+
color='neutral'
148+
size='sm'
149+
variant='outline'
150+
onClick={updateUsername}
151+
>
152+
Update username
153+
</Button>
154+
</Section.Actions>
155+
) : null}
156+
</Section.Item>
157+
</Section.Row>
158+
<ContactSection
159+
items={emails}
160+
kind='email'
161+
label='Email'
162+
onAdd={onAddEmail}
163+
onManage={onManageEmail}
164+
onRemove={onRemoveEmail}
165+
onSetPrimary={onSetPrimaryEmail}
166+
onVerify={onVerifyEmail}
167+
/>
168+
<ContactSection
169+
items={phones}
170+
kind='phone'
171+
label='Phone'
172+
onAdd={onAddPhone}
173+
onManage={onManagePhone}
174+
onRemove={onRemovePhone}
175+
onSetPrimary={onSetPrimaryPhone}
176+
onVerify={onVerifyPhone}
177+
/>
178+
</Section.Group>
179+
</Section.Root>
180+
);
181+
}
182+
183+
function ContactSection({
184+
kind,
185+
label,
186+
items,
187+
onAdd,
188+
onManage,
189+
onVerify,
190+
onSetPrimary,
191+
onRemove,
192+
}: {
193+
kind: 'email' | 'phone';
194+
label: string;
195+
items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>;
196+
onAdd?: () => void;
197+
onManage?: (id: string) => void;
198+
onVerify?: (id: string) => void;
199+
onSetPrimary?: (id: string) => void;
200+
onRemove?: (id: string) => void;
201+
}) {
202+
const labelId = `user-profile-profile-panel-${label.toLowerCase()}`;
203+
204+
return (
205+
<Section.Row
206+
render={props => (
207+
<section
208+
{...props}
209+
aria-labelledby={labelId}
210+
/>
211+
)}
212+
>
213+
<Section.Item>
214+
<Section.Content>
215+
<Section.Label id={labelId}>{label}</Section.Label>
216+
</Section.Content>
217+
{onAdd ? (
218+
<Section.Actions>
219+
<Button
220+
color='neutral'
221+
size='sm'
222+
variant='outline'
223+
onClick={onAdd}
224+
>
225+
Add
226+
<Icon
227+
name='plus'
228+
placement='inline-end'
229+
size='sm'
230+
/>
231+
</Button>
232+
</Section.Actions>
233+
) : null}
234+
</Section.Item>
235+
<Section.Items>
236+
{items.map(item => {
237+
const actions: UserProfileMenuAction[] = [];
238+
const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove);
239+
240+
if (item.isVerified === false && onVerify) {
241+
actions.push({
242+
label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number',
243+
onClick: () => onVerify(item.id),
244+
});
245+
} else if (!item.isDefault && item.isVerified === true && onSetPrimary) {
246+
actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) });
247+
}
248+
249+
if (onRemove && item.canRemove !== false) {
250+
actions.push({
251+
label: kind === 'email' ? 'Remove email' : 'Remove phone number',
252+
color: 'negative',
253+
onClick: () => onRemove(item.id),
254+
});
255+
}
256+
257+
if (!hasExplicitActions && onManage) {
258+
actions.push({ label: 'Manage', onClick: () => onManage(item.id) });
259+
}
260+
261+
return (
262+
<Section.Item key={item.id}>
263+
<Section.Content>
264+
<Section.Description style={{ alignItems: 'center', display: 'flex', gap: 4 }}>
265+
<span>{item.value}</span>
266+
{item.isDefault ? <Badge color='neutral'>Primary</Badge> : null}
267+
</Section.Description>
268+
</Section.Content>
269+
{actions.length > 0 ? (
270+
<Section.Actions>
271+
<UserProfileActionMenu
272+
actions={actions}
273+
label={`Manage ${item.value}`}
274+
/>
275+
</Section.Actions>
276+
) : null}
277+
</Section.Item>
278+
);
279+
})}
280+
</Section.Items>
281+
</Section.Row>
282+
);
283+
}

0 commit comments

Comments
 (0)