;
}
export function UserProfileAccountSectionView({
@@ -88,12 +81,26 @@ export function UserProfileAccountSectionView({
onVerifyEmail,
onSetPrimaryEmail,
onRemoveEmail,
- onAddPhone,
+ onSendPhoneCode,
+ onVerifyPhoneCode,
onManagePhone,
onVerifyPhone,
onSetPrimaryPhone,
onRemovePhone,
}: UserProfileAccountSectionViewProps) {
+ const phoneRow = (
+
+ );
+
return (
@@ -120,7 +127,7 @@ export function UserProfileAccountSectionView({
onSubmit={onSubmitUsername}
/>
{!allowMultipleAccounts ? (
-
) : null}
- {!allowMultipleAccounts ? (
-
- ) : null}
+ {!allowMultipleAccounts ? phoneRow : null}
{allowMultipleAccounts ? (
-
+
+
+
+
+
) : null}
{allowMultipleAccounts ? (
-
+
+ {phoneRow}
+
) : null}
);
}
-
-interface ContactSectionProps {
- 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;
-}
-
-function ContactSection(props: ContactSectionProps) {
- return (
-
-
-
-
-
- );
-}
-
-function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) {
- const item = items[0];
- const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd;
- const emptyDescription = m[kind].empty;
- const actionLabel = item ? m[kind].update : m[kind].add;
-
- return (
-
-
-
- {label}
- {item ? (
-
- {item.value}
- {item.isDefault ? {m.primary} : null}
-
- ) : (
- {emptyDescription}
- )}
-
- {onClick ? (
-
-
-
- ) : null}
-
-
- );
-}
-
-function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) {
- const emptyDescription = m[kind].empty;
-
- return (
-
-
-
- {label}
-
- {onAdd ? (
-
-
-
- ) : null}
-
-
- {items.length === 0 ? (
-
-
- {emptyDescription}
-
-
- ) : (
- items.map(item => {
- const actions: UserProfileMenuAction[] = [];
- const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove);
-
- if (item.isVerified === false && onVerify) {
- actions.push({
- label: item.isDefault ? m.completeVerification : m[kind].verify,
- onClick: () => onVerify(item.id),
- });
- } else if (!item.isDefault && item.isVerified === true && onSetPrimary) {
- actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
- }
-
- if (onRemove && item.canRemove !== false) {
- actions.push({
- label: m[kind].remove,
- color: 'negative',
- onClick: () => onRemove(item.id),
- });
- }
-
- if (!hasExplicitActions && onManage) {
- actions.push({ label: m.manage, onClick: () => onManage(item.id) });
- }
-
- return (
-
-
-
- {item.value}
- {item.isDefault ? {m.primary} : null}
-
-
- {actions.length > 0 ? (
-
-
-
- ) : null}
-
- );
- })
- )}
-
-
- );
-}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts
new file mode 100644
index 00000000000..571fb00a042
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts
@@ -0,0 +1,153 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller';
+
+describe('useUserProfileAddPhoneController', () => {
+ afterEach(() => vi.useRealTimers());
+
+ it('keeps the resend countdown running while verification is pending', async () => {
+ vi.useFakeTimers();
+ const verification = Promise.withResolvers();
+ const { result } = renderHook(() =>
+ useUserProfileAddPhoneController({
+ onSend: () => Promise.resolve(),
+ onVerify: () => verification.promise,
+ }),
+ );
+ act(() => result.current.onOpenChange(true));
+ await act(async () => {
+ result.current.onSubmit();
+ await Promise.resolve();
+ });
+ act(() => result.current.onSubmit('123456'));
+ for (let second = 0; second < 12; second++) {
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
+ }
+ expect(result.current.resendSeconds).toBe(0);
+ await act(async () => {
+ verification.reject(new Error('Incorrect code'));
+ await Promise.resolve();
+ });
+ expect(result.current.errorMessage).toBe('Incorrect code');
+ expect(result.current.resendSeconds).toBe(0);
+ });
+
+ it('starts with the supplied phone number', () => {
+ const { result } = renderHook(() =>
+ useUserProfileAddPhoneController({
+ initialPhoneNumber: '+18015558181',
+ onSend: () => Promise.resolve(),
+ onVerify: () => Promise.resolve(),
+ }),
+ );
+ act(() => result.current.onOpenChange(true));
+ expect(result.current.phoneNumber).toBe('+18015558181');
+ });
+
+ it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => {
+ const request = Promise.withResolvers();
+ const onSend = vi.fn(() => request.promise);
+ const { result } = renderHook(() =>
+ useUserProfileAddPhoneController({ onSend, onVerify: () => Promise.resolve() }),
+ );
+ act(() => result.current.onOpenChange(true));
+ act(() => result.current.onPhoneNumberChange('+18015550100'));
+ act(() => {
+ result.current.onSubmit();
+ result.current.onSubmit();
+ result.current.onPhoneNumberChange('+18015550200');
+ result.current.onOpenChange(false);
+ });
+ expect(result.current.open).toBe(true);
+ expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100');
+ await act(async () => {
+ request.resolve();
+ await request.promise;
+ });
+ act(() => result.current.onCodeChange('123'));
+ act(() => result.current.onOpenChange(false));
+ expect(result.current.open).toBe(false);
+ act(() => result.current.onOpenChange(true));
+ expect(result.current.step).toBe('phone');
+ expect(result.current.code).toBe('');
+ expect(result.current.resendSeconds).toBe(0);
+ expect(result.current.errorMessage).toBeUndefined();
+ });
+
+ it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => {
+ vi.useFakeTimers();
+ const onSend = vi.fn(() => Promise.resolve());
+ const onVerify = vi.fn(() => Promise.resolve());
+ const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify }));
+ act(() => result.current.onOpenChange(true));
+ await act(async () => {
+ result.current.onSubmit();
+ await Promise.resolve();
+ });
+ expect(result.current.resendSeconds).toBe(12);
+ act(() => result.current.onResend());
+ expect(onSend).toHaveBeenCalledTimes(1);
+ for (let second = 0; second < 12; second++) {
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
+ }
+ expect(result.current.resendSeconds).toBe(0);
+ act(() => result.current.onCodeChange('123'));
+ await act(async () => {
+ result.current.onResend();
+ result.current.onResend();
+ result.current.onSubmit('123456');
+ result.current.onOpenChange(false);
+ await Promise.resolve();
+ });
+ expect(onSend).toHaveBeenCalledTimes(2);
+ expect(onVerify).not.toHaveBeenCalled();
+ expect(result.current.open).toBe(true);
+ expect(result.current.code).toBe('');
+ expect(result.current.resendSeconds).toBe(12);
+ });
+ it.each(['phone', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => {
+ const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined);
+ const { result } = renderHook(() =>
+ useUserProfileAddPhoneController({
+ onSend: step === 'phone' ? operation : () => Promise.resolve(),
+ onVerify: step === 'verify' ? operation : () => Promise.resolve(),
+ }),
+ );
+ act(() => result.current.onOpenChange(true));
+ act(() => result.current.onPhoneNumberChange('+18015550100'));
+ act(() => result.current.onSubmit());
+ if (step === 'verify') {
+ await waitFor(() => expect(result.current.step).toBe('verify'));
+ act(() => result.current.onSubmit('000000'));
+ }
+ await waitFor(() => expect(result.current.errorMessage).toBe('Try again'));
+ expect(result.current.isPending).toBe(false);
+ expect(result.current.step).toBe(step);
+ expect(result.current.phoneNumber).toBe('+18015550100');
+ if (step === 'verify') {
+ expect(result.current.code).toBe('000000');
+ }
+ act(() => result.current.onSubmit());
+ await waitFor(() => expect(operation).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(result.current.errorMessage).toBeUndefined());
+ });
+ it('sends a code, verifies the submitted code, and closes on success', async () => {
+ const onSend = vi.fn(() => Promise.resolve());
+ const onVerify = vi.fn(() => Promise.resolve());
+ const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify }));
+
+ expect(result.current.open).toBe(false);
+ act(() => result.current.onOpenChange(true));
+ act(() => result.current.onPhoneNumberChange('+18015550100'));
+ act(() => result.current.onSubmit());
+ expect(result.current.isPending).toBe(true);
+ expect(result.current.open).toBe(true);
+ await waitFor(() => expect(result.current.step).toBe('verify'));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100');
+
+ act(() => result.current.onSubmit('123456'));
+ await waitFor(() => expect(result.current.open).toBe(false));
+ expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456');
+ });
+});
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts
new file mode 100644
index 00000000000..dc1eb1b31c8
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts
@@ -0,0 +1,159 @@
+import { useEffect } from 'react';
+
+import { setup } from '../../machine/setup';
+import { useMachine } from '../../machine/useMachine';
+import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog';
+import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages';
+
+export interface UserProfileAddPhoneControllerOptions {
+ initialPhoneNumber?: string;
+ onSend: (phoneNumber: string) => Promise;
+ onVerify: (phoneNumber: string, code: string) => Promise;
+}
+
+interface Context extends UserProfileAddPhoneControllerOptions {
+ phoneNumber: string;
+ code: string;
+ errorMessage: string | undefined;
+ resendSeconds: number;
+}
+
+type Event =
+ | { type: 'OPEN' }
+ | { type: 'CANCEL' }
+ | { type: 'RESEND' }
+ | { type: 'TICK' }
+ | { type: 'TYPE_PHONE'; value: string }
+ | { type: 'TYPE_CODE'; value: string }
+ | { type: 'SUBMIT'; code?: string };
+
+const { createMachine, assign, fromPromise } = setup();
+
+function missingDependency(): Promise {
+ return Promise.reject(new Error('Add phone callbacks are missing'));
+}
+
+function errorMessage(cause: unknown): string {
+ return cause instanceof Error ? cause.message : m.error;
+}
+
+const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
+
+const machine = createMachine({
+ id: 'addPhone',
+ initial: 'idle',
+ context: {
+ onSend: missingDependency,
+ onVerify: missingDependency,
+ phoneNumber: '',
+ code: '',
+ errorMessage: undefined,
+ resendSeconds: 0,
+ },
+ states: {
+ idle: {
+ on: {
+ OPEN: {
+ target: 'phone',
+ actions: assign(context => ({
+ phoneNumber: context.initialPhoneNumber ?? '',
+ code: '',
+ errorMessage: undefined,
+ resendSeconds: 0,
+ })),
+ },
+ },
+ },
+ phone: {
+ on: {
+ CANCEL: 'idle',
+ TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, errorMessage: undefined })) },
+ SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) },
+ },
+ },
+ sending: {
+ invoke: fromPromise(context => context.onSend(context.phoneNumber), {
+ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) },
+ onError: {
+ target: 'phone',
+ actions: assign((_, event) => ({
+ errorMessage: errorMessage(event.error),
+ })),
+ },
+ }),
+ },
+ verify: {
+ on: {
+ CANCEL: 'idle',
+ TICK: tick,
+ RESEND: {
+ target: 'resending',
+ guard: context => context.resendSeconds === 0,
+ actions: assign(() => ({ errorMessage: undefined })),
+ },
+ TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) },
+ SUBMIT: {
+ target: 'verifying',
+ actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })),
+ },
+ },
+ },
+ resending: {
+ invoke: fromPromise(context => context.onSend(context.phoneNumber), {
+ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) },
+ onError: {
+ target: 'verify',
+ actions: assign((_, event) => ({
+ errorMessage: errorMessage(event.error),
+ })),
+ },
+ }),
+ },
+ verifying: {
+ on: { TICK: tick },
+ invoke: fromPromise(context => context.onVerify(context.phoneNumber, context.code), {
+ onDone: 'idle',
+ onError: {
+ target: 'verify',
+ actions: assign((_, event) => ({
+ errorMessage: errorMessage(event.error),
+ })),
+ },
+ }),
+ },
+ },
+});
+
+export function useUserProfileAddPhoneController(
+ options: UserProfileAddPhoneControllerOptions,
+): UserProfileAddPhoneDialogProps {
+ const [snapshot, send] = useMachine(machine, { context: options });
+ const { resendSeconds } = snapshot.context;
+ const open = snapshot.value !== 'idle';
+ useEffect(() => {
+ if (!open || resendSeconds === 0) {
+ return;
+ }
+ const timer = setTimeout(() => send({ type: 'TICK' }), 1000);
+ return () => clearTimeout(timer);
+ }, [open, resendSeconds, send]);
+
+ return {
+ resendSeconds,
+ isResending: snapshot.value === 'resending',
+ open,
+ step:
+ snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending'
+ ? 'verify'
+ : 'phone',
+ phoneNumber: snapshot.context.phoneNumber,
+ code: snapshot.context.code,
+ errorMessage: snapshot.context.errorMessage,
+ isPending: snapshot.value === 'sending' || snapshot.value === 'verifying',
+ onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
+ onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }),
+ onCodeChange: value => send({ type: 'TYPE_CODE', value }),
+ onSubmit: code => send({ type: 'SUBMIT', code }),
+ onResend: () => send({ type: 'RESEND' }),
+ };
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx
new file mode 100644
index 00000000000..0f420924e03
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx
@@ -0,0 +1,189 @@
+import type { FormEvent } from 'react';
+import { useId, useRef } from 'react';
+
+import { stringToFormattedPhoneString } from '../../../utils/phoneUtils';
+import { Button, SubmitButton } from '../../components/button';
+import { Card } from '../../components/card';
+import type { DialogTriggerProps } from '../../components/dialog';
+import { Dialog } from '../../components/dialog';
+import { Field } from '../../components/field';
+import { Flow } from '../../components/flow';
+import { Otp } from '../../components/otp';
+import { PhoneInput } from '../../components/phone-input';
+import { fill } from './user-profile-account-section.messages';
+import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages';
+
+export interface UserProfileAddPhoneDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ trigger?: DialogTriggerProps['render'];
+ step: 'phone' | 'verify';
+ phoneNumber: string;
+ onPhoneNumberChange: (value: string) => void;
+ code: string;
+ onCodeChange: (value: string) => void;
+ onSubmit: (code?: string) => void;
+ onResend: () => void;
+ isPending?: boolean;
+ errorMessage?: string;
+ isResending?: boolean;
+ resendSeconds?: number;
+}
+
+export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) {
+ const phoneFormId = useId();
+ const verifyFormId = useId();
+ const phoneRef = useRef(null);
+ const verifyRef = useRef(null);
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ if (!props.isPending && !props.isResending) {
+ props.onSubmit();
+ }
+ };
+
+ return (
+
+ {props.trigger ? : null}
+
+ phoneRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true
+ }
+ >
+
+
+ {current => (
+ <>
+
+
+ {m.phone.title}
+ {m.phone.description}
+
+
+ }
+ >
+
+ {m.phone.label}
+
+ {current.errorMessage ? {current.errorMessage} : null}
+
+
+
+
+ {m.phone.submit}
+
+
+
+
+
+ {m.verify.title}
+
+ {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(current.phoneNumber) })}
+
+
+
+ }
+ >
+
+ {m.verify.label}
+ {
+ if (!current.isPending && !current.isResending) {
+ current.onSubmit(code);
+ }
+ }}
+ />
+ {current.errorMessage ? {current.errorMessage} : null}
+
+
+
+
+
+ }
+ >
+ {m.verify.cancel}
+
+
+ {m.verify.submit}
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts
new file mode 100644
index 00000000000..d9883904dff
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts
@@ -0,0 +1,21 @@
+export const userProfileAddPhoneMessages = {
+ error: 'Something went wrong. Please try again.',
+ phone: {
+ title: 'Add phone number',
+ description: 'We’ll send you a text to verify this phone number. Message and data rates may apply.',
+ label: 'Phone',
+ submit: 'Send code',
+ pending: 'Sending code',
+ },
+ verify: {
+ title: 'Verify your phone number',
+ description: 'Enter the code sent to {phoneNumber}',
+ label: 'Verification code',
+ submit: 'Verify',
+ pending: 'Verifying',
+ cancel: 'Cancel',
+ resend: 'Didn’t receive a code? Resend',
+ resending: 'Sending a new code…',
+ resendCountdown: 'Didn’t receive a code? Resend ({seconds})',
+ },
+};
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx
new file mode 100644
index 00000000000..574eb406385
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx
@@ -0,0 +1,125 @@
+import * as stylex from '@stylexjs/stylex';
+import type { ReactNode } from 'react';
+
+import { Badge } from '../../components/badge';
+import { Button } from '../../components/button';
+import { Icon } from '../../components/icon';
+import { Section } from '../../components/section';
+import type { UserProfileMenuAction } from '../user-profile-action-menu';
+import { UserProfileActionMenu } from '../user-profile-action-menu';
+import { styles } from '../user-profile-profile-panel.styles';
+import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages';
+
+export interface UserProfileContactListRowViewProps {
+ addAction?: ReactNode;
+ 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;
+ renderActionDialog?: (item: { id: string; value: string }) => ReactNode;
+}
+
+export function UserProfileContactListRowView({
+ kind,
+ label,
+ items,
+ onAdd,
+ onManage,
+ onVerify,
+ onSetPrimary,
+ onRemove,
+ addAction,
+ renderActionDialog,
+}: UserProfileContactListRowViewProps) {
+ const emptyDescription = m[kind].empty;
+
+ return (
+
+
+
+ {label}
+
+ {addAction ? (
+ {addAction}
+ ) : onAdd ? (
+
+
+
+ ) : null}
+
+
+ {items.length === 0 ? (
+
+
+ {emptyDescription}
+
+
+ ) : (
+ items.map(item => {
+ const actions: UserProfileMenuAction[] = [];
+ const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove);
+
+ if (item.isVerified === false && onVerify) {
+ actions.push({
+ label: item.isDefault ? m.completeVerification : m[kind].verify,
+ onClick: () => onVerify(item.id),
+ });
+ } else if (!item.isDefault && item.isVerified === true && onSetPrimary) {
+ actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
+ }
+
+ if (onRemove && item.canRemove !== false) {
+ actions.push({
+ label: m[kind].remove,
+ color: 'negative',
+ onClick: () => onRemove(item.id),
+ });
+ }
+
+ if (!hasExplicitActions && onManage) {
+ actions.push({ label: m.manage, onClick: () => onManage(item.id) });
+ }
+
+ return (
+
+
+
+ {item.value}
+ {item.isDefault ? {m.primary} : null}
+
+
+ {actions.length > 0 ? (
+
+
+ {renderActionDialog?.(item)}
+
+
+ ) : null}
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx
new file mode 100644
index 00000000000..6ab1783ae3d
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx
@@ -0,0 +1,63 @@
+import * as stylex from '@stylexjs/stylex';
+import type { ReactNode } from 'react';
+
+import { Badge } from '../../components/badge';
+import { Button } from '../../components/button';
+import { Section } from '../../components/section';
+import { styles } from '../user-profile-profile-panel.styles';
+import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages';
+
+export interface UserProfileContactRowViewProps {
+ kind: 'email' | 'phone';
+ label: string;
+ items: Array<{ id: string; value: string; isDefault?: boolean }>;
+ onAdd?: () => void;
+ onManage?: (id: string) => void;
+ addAction?: ReactNode;
+}
+
+export function UserProfileContactRowView({
+ kind,
+ label,
+ items,
+ onAdd,
+ onManage,
+ addAction,
+}: UserProfileContactRowViewProps) {
+ const item = items[0];
+ const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd;
+ const emptyDescription = m[kind].empty;
+ const actionLabel = item ? m[kind].update : m[kind].add;
+
+ return (
+
+
+
+ {label}
+ {item ? (
+
+ {item.value}
+ {item.isDefault ? {m.primary} : null}
+
+ ) : (
+ {emptyDescription}
+ )}
+
+ {!item && addAction ? (
+ {addAction}
+ ) : onClick ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx
new file mode 100644
index 00000000000..0137c60b092
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx
@@ -0,0 +1,197 @@
+import type { Ref } from 'react';
+import { useRef, useState } from 'react';
+
+import { stringToFormattedPhoneString } from '../../../utils/phoneUtils';
+import { Button } from '../../components/button';
+import { Icon } from '../../components/icon';
+import { Text } from '../../components/text';
+import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages';
+import type { UserProfilePhone } from './user-profile-account-section.types';
+import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller';
+import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller';
+import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog';
+import { UserProfileContactListRowView } from './user-profile-contact-list-row.view';
+import { UserProfileContactRowView } from './user-profile-contact-row.view';
+import { UserProfileRemovePhoneDialog } from './user-profile-remove-phone.dialog';
+
+export interface UserProfilePhoneRowViewProps {
+ phones: UserProfilePhone[];
+ allowMultipleAccounts?: boolean;
+ onSendPhoneCode?: (phoneNumber: string) => Promise;
+ onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise;
+ onManagePhone?: (id: string) => void;
+ onVerifyPhone?: (id: string) => void;
+ onSetPrimaryPhone?: (id: string) => void | Promise;
+ onRemovePhone?: (id: string) => void | Promise;
+}
+
+export function UserProfilePhoneRowView({
+ phones,
+ allowMultipleAccounts = false,
+ onSendPhoneCode,
+ onVerifyPhoneCode,
+ onManagePhone,
+ onVerifyPhone,
+ onSetPrimaryPhone,
+ onRemovePhone,
+}: UserProfilePhoneRowViewProps) {
+ const addPhoneTriggerRef = useRef(null);
+ const addPhoneAction =
+ onSendPhoneCode && onVerifyPhoneCode ? (
+
+ ) : undefined;
+ const confirmedRemoval = useRef(false);
+ const [phoneToRemove, setPhoneToRemove] = useState();
+ const [removeError, setRemoveError] = useState();
+ const removing = useRef(false);
+ const [isSettingPrimary, setIsSettingPrimary] = useState(false);
+ const [primaryError, setPrimaryError] = useState();
+ const settingPrimary = useRef(false);
+
+ const setPrimaryPhone = async (id: string) => {
+ const phone = phones.find(phone => phone.id === id);
+ if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) {
+ return;
+ }
+ settingPrimary.current = true;
+ setIsSettingPrimary(true);
+ setPrimaryError(undefined);
+ try {
+ await onSetPrimaryPhone(id);
+ } catch (error) {
+ setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError);
+ } finally {
+ settingPrimary.current = false;
+ setIsSettingPrimary(false);
+ }
+ };
+
+ const removePhone = (id: string) => {
+ const phone = phones.find(phone => phone.id === id);
+ if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) {
+ return;
+ }
+ confirmedRemoval.current = false;
+ setPhoneToRemove(phone);
+ setRemoveError(undefined);
+ };
+
+ const confirmRemovePhone = async () => {
+ if (!phoneToRemove || !onRemovePhone || removing.current) {
+ return;
+ }
+ removing.current = true;
+ confirmedRemoval.current = true;
+ setPhoneToRemove(undefined);
+ try {
+ await onRemovePhone(phoneToRemove.id);
+ } catch (error) {
+ setRemoveError(error instanceof Error ? error.message : m.phone.removeError);
+ } finally {
+ removing.current = false;
+ }
+ };
+ const formattedPhones = phones.map(phone => ({
+ ...phone,
+ value: stringToFormattedPhoneString(phone.value),
+ }));
+
+ if (!allowMultipleAccounts) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ void setPrimaryPhone(id) : undefined}
+ onVerify={onVerifyPhone}
+ renderActionDialog={
+ onRemovePhone
+ ? phone => (
+ {
+ if (!open) {
+ setPhoneToRemove(undefined);
+ }
+ }}
+ onConfirm={() => void confirmRemovePhone()}
+ finalFocus={() => (confirmedRemoval.current ? addPhoneTriggerRef.current : undefined)}
+ />
+ )
+ : undefined
+ }
+ />
+ {primaryError ? (
+
+ {primaryError}
+
+ ) : null}
+ {removeError ? (
+
+ {removeError}
+
+ ) : null}
+ >
+ );
+}
+
+function AddPhone({
+ options,
+ compact,
+ triggerRef,
+}: {
+ options: UserProfileAddPhoneControllerOptions;
+ compact: boolean;
+ triggerRef?: Ref;
+}) {
+ const controller = useUserProfileAddPhoneController(options);
+ return (
+
+ {compact ? (
+
+ ) : null}
+ {compact ? m.add : m.phone.add}
+
+ }
+ />
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx
new file mode 100644
index 00000000000..809555a88e8
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx
@@ -0,0 +1,53 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { Button } from '../../components/button';
+import type { DialogFocusTarget } from '../../components/dialog';
+import { Dialog } from '../../components/dialog';
+import { Heading } from '../../components/heading';
+import { Text } from '../../components/text';
+import { styles } from '../user-profile-profile-panel.styles';
+import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages';
+
+export interface UserProfileRemovePhoneDialogProps {
+ phoneNumber: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+ finalFocus?: DialogFocusTarget;
+}
+
+export function UserProfileRemovePhoneDialog({
+ phoneNumber,
+ open,
+ onOpenChange,
+ onConfirm,
+ finalFocus,
+}: UserProfileRemovePhoneDialogProps) {
+ const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}');
+
+ return (
+
+
+ }>{m.phone.removeDialog.title}
+ }>
+ {beforePhone}
+ {phoneNumber}
+ {afterPhone}
+
+
+ }>{m.phone.removeDialog.cancel}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx
index 288bd38c2ba..c2f4041c6b6 100644
--- a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx
+++ b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx
@@ -1,3 +1,5 @@
+import type { ReactNode } from 'react';
+
import { Icon } from '../components/icon';
import { Menu } from '../components/menu';
import type { IconName } from '../icons/registry';
@@ -9,7 +11,15 @@ export interface UserProfileMenuAction {
onClick: () => void;
}
-export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) {
+export function UserProfileActionMenu({
+ label,
+ actions,
+ children,
+}: {
+ label: string;
+ actions: UserProfileMenuAction[];
+ children?: ReactNode;
+}) {
if (actions.length === 0) {
return null;
}
@@ -34,6 +44,7 @@ export function UserProfileActionMenu({ label, actions }: { label: string; actio
))}
+ {children}
);
}
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
index 8a3cc17a27c..fd3e6967fb3 100644
--- 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
@@ -1,8 +1,11 @@
import * as stylex from '@stylexjs/stylex';
-import { space } from '../tokens.stylex';
+import { fontWeightVars, space } from '../tokens.stylex';
export const styles = stylex.create({
+ confirmPhoneNumber: {
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ },
contactValue: {
gap: space['2'],
alignItems: 'center',
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
index fe998896899..44f921e8b2f 100644
--- 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
@@ -61,7 +61,8 @@ export function UserProfileProfilePanelView({
onVerifyEmail,
onSetPrimaryEmail,
onRemoveEmail,
- onAddPhone,
+ onSendPhoneCode,
+ onVerifyPhoneCode,
onManagePhone,
onVerifyPhone,
onSetPrimaryPhone,
@@ -92,7 +93,8 @@ export function UserProfileProfilePanelView({
phones={phones}
username={username}
onAddEmail={onAddEmail}
- onAddPhone={onAddPhone}
+ onSendPhoneCode={onSendPhoneCode}
+ onVerifyPhoneCode={onVerifyPhoneCode}
onManageEmail={onManageEmail}
onManagePhone={onManagePhone}
onProfilePictureChange={onProfilePictureChange}