diff --git a/.changeset/mosaic-user-profile-profile-panel.md b/.changeset/mosaic-user-profile-profile-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-profile-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 4a700973eef..b1527362df6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + user: { + 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 420f857cfe0..959a0e95455 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -116,6 +116,10 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Default as UserProfileProfilePanelDefault, + meta as userProfileProfilePanelMeta, +} from '../stories/user-profile-profile-panel.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -242,7 +246,14 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileProfilePanelModule: StoryModule = { + meta: userProfileProfilePanelMeta, + Default: UserProfileProfilePanelDefault, +}; + export const registry: StoryModule[] = [ + // User + userProfileProfilePanelModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, diff --git a/packages/swingset/src/stories/icon.stories.tsx b/packages/swingset/src/stories/icon.stories.tsx index d3bc7eed11c..0bc34de25ff 100644 --- a/packages/swingset/src/stories/icon.stories.tsx +++ b/packages/swingset/src/stories/icon.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { styleEngine: 'stylex', styles: { _variants: { - size: { sm: {}, md: {}, lg: {} }, + size: { xs: {}, sm: {}, md: {}, lg: {} }, }, _defaultVariants: { size: 'md', @@ -44,6 +44,11 @@ export function Default(props: Record) { export function Sizes(props: Record) { return (
+ + +## Usage + +```tsx +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + + ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + }))} + phones={user.phoneNumbers.map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + }))} + connectedAccounts={user.externalAccounts.map(account => ({ + id: account.id, + provider: account.provider, + identifier: account.emailAddress, + connected: true, + }))} + onNameChange={setName} + onUsernameChange={setUsername} + onVerifyEmail={verifyEmail} + onSetPrimaryEmail={setPrimaryEmail} + onRemoveEmail={removeEmail} + onVerifyPhone={verifyPhone} + onSetPrimaryPhone={setPrimaryPhone} + onRemovePhone={removePhone} + onRemoveConnectedAccount={removeConnectedAccount} +/> +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx new file mode 100644 index 00000000000..d1813c7714c --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -0,0 +1,58 @@ +/** @jsxImportSource @emotion/react */ +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; +const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; + +export { default as __source } from './user-profile-profile-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileProfilePanel', + source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', +}; + +export function Default(_args: Record) { + const [name, setName] = useState('Preston Booth'); + const [username, setUsername] = useState('prestonxyz'); + + return ( + undefined} + onAddPhone={() => undefined} + onConnectAccount={() => undefined} + onDeleteAccount={() => undefined} + onEditProfilePicture={() => undefined} + onRemoveConnectedAccount={() => undefined} + onRemoveEmail={() => undefined} + onRemovePhone={() => undefined} + onSetPrimaryEmail={() => undefined} + onSetPrimaryPhone={() => undefined} + onVerifyEmail={() => undefined} + onVerifyPhone={() => undefined} + onNameChange={setName} + onUsernameChange={setUsername} + /> + ); +} diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 1ed40b76011..2200f853e83 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -20,6 +20,7 @@ export const styles = stylex.create({ }); export const sizes = stylex.create({ + xs: { height: space['3'], width: space['3'] }, sm: { height: space['3.5'], width: space['3.5'] }, md: { height: space['4'], width: space['4'] }, lg: { height: space['5'], width: space['5'] }, diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index 8da9ee9dfd5..7a6622dfcda 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -41,6 +41,19 @@ describe('Mosaic Icon', () => { expect(svg).toHaveStyle({ marginTop: '8px' }); }); + it('renders the pen with its native 12px geometry', () => { + const { container } = wrap( + , + ); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('data-size', 'xs'); + expect(svg).toHaveAttribute('viewBox', '0 0 12 12'); + expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); + }); + it('emits no placement attribute when the icon is not placed', () => { const { container } = wrap(); expect(container.querySelector('svg')).not.toHaveAttribute('data-icon'); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index b5f756068b4..f8716d1d757 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { name: IconName; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 8269dd519fa..ec7529341a7 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent< * Builds a glyph from its inner `` markup. Glyphs omit `width`/`height` so the `Icon` recipe * controls size, and use `currentColor` so they inherit text color. Grow the set on demand. */ -function glyph(children: React.ReactNode): IconComponent { +function glyph(children: React.ReactNode, viewBox = '0 0 16 16'): IconComponent { return React.forwardRef>((props, ref) => ( , ); +const ArrowRightTop = glyph( + , +); + +const Pen = glyph( + , + '0 0 12 12', +); + const LogOut = glyph( ; diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx new file mode 100644 index 00000000000..c64807318be --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -0,0 +1,222 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +const props: UserProfileProfilePanelViewProps = { + name: 'Preston Booth', + username: 'prestonxyz', + emails: [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true }, + { id: 'email_2', value: 'item2@clerk.dev' }, + ], + phones: [{ id: 'phone_1', value: '+1 801-888-8181' }], +}; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfileProfilePanelView', () => { + it('composes the profile content without profile navigation', () => { + renderView({ onEditProfilePicture: vi.fn() }); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Account' }).nextElementSibling).toHaveClass('cl-card-root'); + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const usernameInput = screen.getByRole('textbox', { name: 'Username' }); + expect(nameInput).toHaveValue('Preston Booth'); + expect(usernameInput).toHaveValue('prestonxyz'); + expect(nameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '50%', flexShrink: 0 }); + expect(usernameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '50%', flexShrink: 0 }); + expect(nameInput.closest('.cl-field-root')).not.toBeNull(); + expect(usernameInput.closest('.cl-field-root')).not.toBeNull(); + expect(screen.getByText('Name')).toHaveAttribute('for', nameInput.id); + expect(screen.getByText('Username')).toHaveAttribute('for', usernameInput.id); + expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('Profile picture')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('Email')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('Phone')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('item1@clerk.dev')).toHaveAttribute('data-color', 'primary'); + const editProfilePicture = screen.getByRole('button', { name: 'Edit profile picture' }); + expect(editProfilePicture).toHaveAttribute('data-size', 'lg'); + expect(editProfilePicture).toHaveStyle({ borderWidth: 0, position: 'relative' }); + expect(editProfilePicture.querySelector('.cl-avatar')).not.toBeNull(); + expect(screen.queryByRole('tab')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); + }); + + it('edits the profile picture when the overhanging pen treatment is clicked', async () => { + const onEditProfilePicture = vi.fn(); + const user = userEvent.setup(); + renderView({ onEditProfilePicture }); + + const button = screen.getByRole('button', { name: 'Edit profile picture' }); + const penTreatment = button.querySelector('svg')?.parentElement; + expect(penTreatment).not.toBeNull(); + await user.click(penTreatment!); + + expect(onEditProfilePicture).toHaveBeenCalledOnce(); + }); + + it('renders connected accounts and the danger zone when provided', async () => { + const onConnectAccount = vi.fn(); + const onManageConnectedAccount = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + renderView({ + connectedAccounts: [ + { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'apple', provider: 'Apple', connected: false }, + ], + onConnectAccount, + onManageConnectedAccount, + onDeleteAccount, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: 'p' })).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('This action is permanent and irreversible.')).toHaveAttribute('data-color', 'neutral'); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + expect(onManageConnectedAccount).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); + expect(onConnectAccount).toHaveBeenCalledWith('apple'); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + it('renders safely before profile data is available', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + }); + + it('forwards field and contact actions', async () => { + const onNameChange = vi.fn(); + const onAddEmail = vi.fn(); + const onManageEmail = vi.fn(); + renderView({ onNameChange, onAddEmail, onManageEmail }); + const user = userEvent.setup(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' Jr.'); + await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add' })); + await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); + expect(onManageEmail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + + expect(onNameChange).toHaveBeenCalled(); + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManageEmail).toHaveBeenCalledWith('email_2'); + }); + + it('matches the existing conditional contact and connected-account actions', async () => { + const onVerifyEmail = vi.fn(); + const onSetPrimaryEmail = vi.fn(); + const onRemoveEmail = vi.fn(); + const onVerifyPhone = vi.fn(); + const onSetPrimaryPhone = vi.fn(); + const onRemovePhone = vi.fn(); + const onRemoveConnectedAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + emails: [ + { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, + { id: 'email_unverified', value: 'unverified@clerk.dev', isVerified: false }, + ], + phones: [ + { id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + ], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onRemoveConnectedAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Complete verification' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_primary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); + expect(removeEmail).toHaveAttribute('data-color', 'negative'); + await user.click(removeEmail); + expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); + expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' }); + expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative'); + await user.click(removeConnectedAccount); + expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github'); + }); + + it('hides action triggers when immutable items have no available actions', () => { + renderView({ + emails: [ + { + id: 'email_immutable', + value: 'immutable@clerk.dev', + isDefault: true, + isVerified: true, + canRemove: false, + }, + ], + phones: [], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], + onVerifyEmail: vi.fn(), + onSetPrimaryEmail: vi.fn(), + onRemoveEmail: vi.fn(), + onRemoveConnectedAccount: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts new file mode 100644 index 00000000000..d373b3db209 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -0,0 +1,91 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + avatarControl: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + position: 'relative', + }, + avatarEditSurface: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + alignItems: 'center', + backgroundColor: colorVars['--cl-color-card'], + boxSizing: 'border-box', + display: 'flex', + insetInlineStart: '-4.5px', + justifyContent: 'center', + position: 'absolute', + height: '20px', + top: '25px', + width: '20px', + }, + card: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-container'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + rowGap: 0, + }, + contactHeader: { + gap: space['3'], + paddingInline: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + paddingBlockStart: space['4'], + }, + contactList: { + paddingBlock: space['2'], + paddingInline: space['2'], + }, + contactValue: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + dangerContent: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + divider: { + marginInline: space['4'], + backgroundColor: colorVars['--cl-color-border'], + height: '1px', + }, + providerIcon: { + flexShrink: 0, + objectFit: 'contain', + height: space['6'], + width: space['6'], + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + gap: space['4'], + paddingBlock: space['4'], + paddingInline: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + minHeight: space['13'], + }, + section: { + gap: space['2'], + display: 'flex', + flexDirection: 'column', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx new file mode 100644 index 00000000000..1a5c73a4164 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -0,0 +1,595 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ChangeEvent, ReactElement } from 'react'; +import { Fragment } from 'react'; + +import { Avatar } from '../components/avatar'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import { Field } from '../components/field'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { Item } from '../components/item'; +import { Menu } from '../components/menu'; +import { Text } from '../components/text'; +import { mergeStyleProps, themeProps } from '../props'; +import { colorVars, fontWeightVars, space } from '../tokens.stylex'; +import { styles } from './user-profile-profile-panel.styles'; + +export interface UserProfileEmail { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfilePhone { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfileConnectedAccount { + id: string; + provider: string; + identifier?: string; + iconUrl?: string; + connected?: boolean; + canRemove?: boolean; +} + +export interface UserProfileProfilePanelViewProps { + imageUrl?: string; + name: string; + username: string; + emails: UserProfileEmail[]; + phones: UserProfilePhone[]; + connectedAccounts?: UserProfileConnectedAccount[]; + onEditProfilePicture?: () => void; + onNameChange?: (value: string) => void; + onUsernameChange?: (value: string) => void; + onAddEmail?: () => void; + onManageEmail?: (id: string) => void; + onVerifyEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void; + onRemoveEmail?: (id: string) => void; + onAddPhone?: () => void; + onManagePhone?: (id: string) => void; + onVerifyPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void; + onRemovePhone?: (id: string) => void; + onConnectAccount?: (id: string) => void; + onManageConnectedAccount?: (id: string) => void; + onRemoveConnectedAccount?: (id: string) => void; + onDeleteAccount?: () => void; +} + +interface ProfileMenuAction { + label: string; + color?: 'neutral' | 'negative'; + onClick: () => void; +} + +function ProfileActionMenu({ label, actions }: { label: string; actions: ProfileMenuAction[] }) { + if (actions.length === 0) { + return null; + } + + return ( + + + + {actions.map(action => ( + + ))} + + + ); +} + +function Divider() { + return
; +} + +function FieldRow({ label, value, onChange }: { label: string; value: string; onChange?: (value: string) => void }) { + const handleChange = (event: ChangeEvent) => onChange?.(event.target.value); + + return ( + + {label} + + + ); +} + +function AccountSection({ + imageUrl, + name, + username, + emails, + phones, + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onAddPhone, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, +}: Pick< + UserProfileProfilePanelViewProps, + | 'imageUrl' + | 'name' + | 'username' + | 'emails' + | 'phones' + | 'onEditProfilePicture' + | 'onNameChange' + | 'onUsernameChange' + | 'onAddEmail' + | 'onManageEmail' + | 'onVerifyEmail' + | 'onSetPrimaryEmail' + | 'onRemoveEmail' + | 'onAddPhone' + | 'onManagePhone' + | 'onVerifyPhone' + | 'onSetPrimaryPhone' + | 'onRemovePhone' +>) { + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + const avatar = ( + + + {initials} + + ); + + return ( +
+

} + size='xs' + > + Account + + + +
+ + Profile picture + +
+ {onEditProfilePicture ? ( + + ) : ( + avatar + )} +
+
+ + + + + + + +
+
+

+ ); +} + +function ConnectedAccountsSection({ + accounts, + onConnect, + onManage, + onRemove, +}: { + accounts: UserProfileConnectedAccount[]; + onConnect?: (id: string) => void; + onManage?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + return ( +
+

} + size='xs' + > + Connected accounts + + + + + {accounts.map((account, index) => { + const connected = account.connected ?? Boolean(account.identifier); + const actions: ProfileMenuAction[] = []; + if (onRemove && account.canRemove !== false) { + actions.push({ + label: 'Remove', + color: 'negative', + onClick: () => onRemove(account.id), + }); + } else if (!onRemove && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(account.id) }); + } + return ( + + {index > 0 ? ( + + ) : null} + + {account.iconUrl ? ( + + + + ) : null} + + {account.provider} + {account.identifier ? {account.identifier} : null} + + + {connected ? ( + + ) : null} + {!connected && onConnect ? ( + + ) : null} + + + + ); + })} + + + +

+ ); +} + +function DangerZoneSection({ onDelete }: { onDelete: () => void }) { + return ( +
+

} + size='xs' + > + Danger zone + + + +
+
+ Delete account + + This action is permanent and irreversible. + +
+ +
+
+
+

+ ); +} + +function ContactSection({ + kind, + label, + items, + onAdd, + onManage, + onVerify, + onSetPrimary, + onRemove, + divided, +}: { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + onVerify?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; + divided?: boolean; +}) { + return ( +
+
+ + {label} + + {onAdd ? ( + + ) : null} +
+ + {items.map(item => { + const actions: ProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + } + + if (onRemove && item.canRemove !== false) { + actions.push({ + label: kind === 'email' ? 'Remove email' : 'Remove phone number', + color: 'negative', + onClick: () => onRemove(item.id), + }); + } + + if (!hasExplicitActions && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + } + + return ( + + +
+ } + color='primary' + > + {item.value} + + {item.isDefault ? ( + + Default + + ) : null} +
+
+ {actions.length > 0 ? ( + + + + ) : null} +
+ ); + })} +
+ {divided ? : null} +
+ ); +} + +export function UserProfileProfilePanelView({ + imageUrl, + name = '', + username = '', + emails = [], + phones = [], + connectedAccounts = [], + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onAddPhone, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onConnectAccount, + onManageConnectedAccount, + onRemoveConnectedAccount, + onDeleteAccount, +}: UserProfileProfilePanelViewProps): ReactElement { + return ( +
+

} + size='base' + > + Profile + + + {connectedAccounts.length > 0 ? ( + + ) : null} + {onDeleteAccount ? : null} +

+ ); +}