diff --git a/src/CONST.ts b/src/CONST.ts index 2cd614b74816..ab5a67274955 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -67,6 +67,8 @@ const onboardingChoices = { LOOKING_AROUND: 'newDotLookingAround', }; +type OnboardingPurposeType = ValueOf; + const CONST = { MERGED_ACCOUNT_PREFIX: 'MERGED_', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], @@ -3607,6 +3609,272 @@ const CONST = { "We'll send a request to each person so they can pay you back. Let me know if you have any questions!", }, + ONBOARDING_MESSAGES: { + [onboardingChoices.TRACK]: { + message: 'Here are some essential tasks to keep your business spend in shape for tax season.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, in just a few clicks.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.EMPLOYER]: { + message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + subtitle: 'Submit an expense by entering an amount or scanning a receipt.', + message: + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Submit expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet “Cha-ching!” when it’s complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], + }, + [onboardingChoices.MANAGE_TEAM]: { + message: 'Here are some important tasks to help get your team’s expenses under control.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'meetGuide', + autoCompleted: false, + title: 'Meet your setup specialist', + subtitle: '', + message: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) => + `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + + '\n' + + `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`, + }, + { + type: 'setupCategories', + autoCompleted: false, + title: 'Set up categories', + subtitle: 'Set up categories so your team can code expenses for easy reporting.', + message: + 'Here’s how to set up categories:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Categories.\n' + + '4. Enable and disable default categories.\n' + + '5. Click Add categories to make your own.\n' + + '\n' + + 'For more controls like requiring a category for every expense, click Settings.', + }, + { + type: 'addExpenseApprovals', + autoCompleted: false, + title: 'Add expense approvals', + subtitle: 'Add expense approvals to review your team’s spend and keep it under control.', + message: + 'Here’s how to add expense approvals:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click More features.\n' + + '4. Enable Workflows.\n' + + '5. In Workflows, enable Add approvals.\n' + + '\n' + + 'You’ll be set as the expense approver. You can change this to any admin once you invite your team.', + }, + { + type: 'inviteTeam', + autoCompleted: false, + title: 'Invite your team', + subtitle: 'Invite your team to Expensify so they can start tracking expenses today.', + message: + 'Here’s how to invite your team:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Members > Invite member.\n' + + '4. Enter emails or phone numbers. \n' + + '5. Add an invite message if you want.\n' + + '\n' + + 'That’s it! Happy expensing :)', + }, + ], + }, + [onboardingChoices.PERSONAL_SPEND]: { + message: 'Here’s how to track your spend in a few clicks.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, whether you have a receipt or not.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.CHAT_SPLIT]: { + message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'startChat', + autoCompleted: false, + title: 'Start a chat', + subtitle: 'Start a chat with a friend or group using their email or phone number.', + message: + 'Here’s how to start a chat:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Start chat.\n' + + '3. Enter emails or phone numbers.\n' + + '\n' + + 'If any of your friends aren’t using Expensify already, they’ll be invited automatically. \n' + + '\n' + + 'Every chat will also turn into an email or text that they can respond to directly.', + }, + { + type: 'splitExpense', + autoCompleted: false, + title: 'Split an expense', + subtitle: 'Split an expense right in your chat with one or more friends.', + message: + 'Here’s how to request money:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Split expense.\n' + + '3. Scan a receipt or enter an amount.\n' + + '4. Add your friend(s) to the request.\n' + + '\n' + + 'Feel free to add more details if you want, or just send it off. Let’s get you paid back!', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to enable your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Add your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid right into your personal bank account.', + }, + ], + }, + [onboardingChoices.LOOKING_AROUND]: { + message: + '# Welcome to Expensify!\n' + + "Hi there, I'm Concierge. Chat with me here for anything you need.\n" + + '\n' + + "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [], + }, + }, + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', MOBILE_PAGINATION_SIZE: 15, @@ -4352,6 +4620,6 @@ type Country = keyof typeof CONST.ALL_COUNTRIES; type IOUType = ValueOf; type IOUAction = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 819680db0e8a..95d383345ec6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -385,6 +385,8 @@ const ONYXKEYS = { DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ONBOARDING_PERSONAL_DETAILS_FORM: 'onboardingPersonalDetailsForm', ONBOARDING_PERSONAL_DETAILS_FORM_DRAFT: 'onboardingPersonalDetailsFormDraft', + ONBOARDING_PERSONAL_WORK: 'onboardingWorkForm', + ONBOARDING_PERSONAL_WORK_DRAFT: 'onboardingWorkFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', @@ -475,6 +477,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; + [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_WORK]: FormTypes.WorkForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ceb4c217cb6e..ac12fc5de521 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -698,6 +698,7 @@ const ROUTES = { PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational', ONBOARDING_ROOT: 'onboarding', ONBOARDING_PERSONAL_DETAILS: 'onboarding/personal-details', + ONBOARDING_WORK: 'onboarding/work', ONBOARDING_PURPOSE: 'onboarding/purpose', WELCOME_VIDEO_ROOT: 'onboarding/welcome-video', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c839d9d05070..aed70dc1e949 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -293,6 +293,7 @@ const SCREENS = { ONBOARDING: { PERSONAL_DETAILS: 'Onboarding_Personal_Details', PURPOSE: 'Onboarding_Purpose', + WORK: 'Onboarding_Work', }, ONBOARD_ENGAGEMENT: { diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 6827dee44141..165b438a1d4c 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -100,7 +100,7 @@ function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { text="Navigate" onPress={() => { Navigation.dismissModal(); - Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS); + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE); }} /> diff --git a/src/languages/en.ts b/src/languages/en.ts index 87864e7e65f1..1ca1feaba048 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -323,6 +323,7 @@ export default { subtitleText2: 'button above, or create something using the', subtitleText3: 'button below.', }, + businessName: 'Business name', }, location: { useCurrent: 'Use current location', @@ -1316,13 +1317,13 @@ export default { notYou: ({user}: NotYouParams) => `Not ${user}?`, }, onboarding: { - welcome: 'Welcome!', welcomeVideo: { title: 'Welcome to Expensify', description: 'Getting paid is as easy as sending a message.', button: "Let's go", }, whatsYourName: "What's your name?", + whereYouWork: 'Where do you work?', purpose: { title: 'What do you want to do today?', error: 'Please make a selection before continuing', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7572c7f7d28b..112993f848f6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -313,6 +313,7 @@ export default { subtitleText2: 'o crea algo usando el botón', subtitleText3: '.', }, + businessName: 'Nombre del Negocio', }, location: { useCurrent: 'Usar ubicación actual', @@ -1315,13 +1316,13 @@ export default { notYou: ({user}: NotYouParams) => `¿No eres ${user}?`, }, onboarding: { - welcome: '¡Bienvenido!', welcomeVideo: { title: 'Bienvenido a Expensify', description: 'Cobrar es tan fácil como enviar un mensaje.', button: 'Vámonos', }, whatsYourName: '¿Cómo te llamas?', + whereYouWork: '¿Dónde trabajas?', purpose: { title: '¿Qué quieres hacer hoy?', error: 'Por favor, haga una selección antes de continuar.', diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts new file mode 100644 index 000000000000..e3a0309d5113 --- /dev/null +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -0,0 +1,10 @@ +import type {OnboardingPurposeType} from '@src/CONST'; + +type CompleteGuidedSetupParams = { + firstName: string; + lastName: string; + guidedSetupData: string; + engagementChoice: OnboardingPurposeType; +}; + +export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bfa89b5d3bd3..61a0d6870cd5 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -207,6 +207,7 @@ export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrenc export type {default as UpdatePolicyConnectionConfigParams} from './UpdatePolicyConnectionConfigParams'; export type {default as RemovePolicyConnectionParams} from './RemovePolicyConnectionParams'; export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams'; +export type {default as CompleteGuidedSetupParams} from './CompleteGuidedSetupParams'; export type {default as DismissTrackExpenseActionableWhisperParams} from './DismissTrackExpenseActionableWhisperParams'; export type {default as ConvertTrackedExpenseToRequestParams} from './ConvertTrackedExpenseToRequestParams'; export type {default as ShareTrackedExpenseParams} from './ShareTrackedExpenseParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f91b694548ba..e0a46bebc1e3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -141,6 +141,7 @@ const WRITE_COMMANDS = { REOPEN_TASK: 'ReopenTask', COMPLETE_TASK: 'CompleteTask', COMPLETE_ENGAGEMENT_MODAL: 'CompleteEngagementModal', + COMPLETE_GUIDED_SETUP: 'CompleteGuidedSetup', SET_NAME_VALUE_PAIR: 'SetNameValuePair', SET_REPORT_FIELD: 'Report_SetFields', DELETE_REPORT_FIELD: 'RemoveReportField', @@ -343,6 +344,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REOPEN_TASK]: Parameters.ReopenTaskParams; [WRITE_COMMANDS.COMPLETE_TASK]: Parameters.CompleteTaskParams; [WRITE_COMMANDS.COMPLETE_ENGAGEMENT_MODAL]: Parameters.CompleteEngagementModalParams; + [WRITE_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams; [WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams; [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9b96bfa009dc..5ced8b1a06e3 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -379,11 +379,11 @@ function getDBTime(timestamp: string | number = ''): string { /** * Returns the current time plus skew in milliseconds in the format expected by the database */ -function getDBTimeWithSkew(): string { +function getDBTimeWithSkew(timestamp: string | number = ''): string { if (networkTimeSkew > 0) { - return getDBTime(new Date().valueOf() + networkTimeSkew); + return getDBTime(new Date(timestamp).valueOf() + networkTimeSkew); } - return getDBTime(); + return getDBTime(timestamp); } function subtractMillisecondsFromDateTime(dateTime: string, milliseconds: number): string { diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9157d7486c9e..096a88254eae 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -369,7 +369,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie /> + diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 02bfda6ba51b..0be4ac5518f3 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -49,7 +49,6 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps return; } - // Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS)}); Welcome.isOnboardingFlowCompleted({ onNotCompleted: () => Navigation.navigate( diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b0c4da6fbdbc..3964b7dcd074 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -96,14 +96,18 @@ const config: LinkingOptions['config'] = { }, [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: { path: ROUTES.ONBOARDING_ROOT, - initialRouteName: SCREENS.ONBOARDING.PERSONAL_DETAILS, + initialRouteName: SCREENS.ONBOARDING.PURPOSE, screens: { + [SCREENS.ONBOARDING.PURPOSE]: { + path: ROUTES.ONBOARDING_PURPOSE, + exact: true, + }, [SCREENS.ONBOARDING.PERSONAL_DETAILS]: { path: ROUTES.ONBOARDING_PERSONAL_DETAILS, exact: true, }, - [SCREENS.ONBOARDING.PURPOSE]: { - path: ROUTES.ONBOARDING_PURPOSE, + [SCREENS.ONBOARDING.WORK]: { + path: ROUTES.ONBOARDING_WORK, exact: true, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index bc3d0bba637e..f564ee01cbf7 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -722,6 +722,7 @@ type OnboardingModalNavigatorParamList = { [SCREENS.ONBOARDING_MODAL.ONBOARDING]: undefined; [SCREENS.ONBOARDING.PERSONAL_DETAILS]: undefined; [SCREENS.ONBOARDING.PURPOSE]: undefined; + [SCREENS.ONBOARDING.WORK]: undefined; }; type WelcomeVideoModalNavigatorParamList = { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index af55b4ca29be..fc677dedc96e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -139,6 +139,10 @@ type OptimisticAddCommentReportAction = Pick< | 'childStatusNum' | 'childStateNum' | 'errors' + | 'childVisibleActionCount' + | 'childCommenterCount' + | 'childLastVisibleActionCreated' + | 'childOldestFourAccountIDs' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { @@ -3157,7 +3161,7 @@ function addDomainToShortMention(mention: string): string | undefined { * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! */ -function getParsedComment(text: string): string { +function getParsedComment(text: string, shouldEscapeText?: boolean): string { const parser = new ExpensiMark(); const textWithMention = text.replace(CONST.REGEX.SHORT_MENTION, (match) => { const mention = match.substring(1); @@ -3165,7 +3169,7 @@ function getParsedComment(text: string): string { return mentionWithDomain ? `@${mentionWithDomain}` : match; }); - return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention) : lodashEscape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention, {shouldEscapeText}) : lodashEscape(text); } function getReportDescriptionText(report: Report): string { @@ -3186,9 +3190,9 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { return parser.htmlToText(policy.description); } -function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number, createdOffset = 0, shouldEscapeText?: boolean): OptimisticReportAction { const parser = new ExpensiMark(); - const commentText = getParsedComment(text ?? ''); + const commentText = getParsedComment(text ?? '', shouldEscapeText); const isAttachmentOnly = file && !text; const isTextOnly = text && !file; @@ -3225,7 +3229,7 @@ function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, ], automatic: false, avatar: allPersonalDetails?.[accountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(accountID), - created: DateUtils.getDBTimeWithSkew(), + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), message: [ { translationKey: isAttachmentOnly ? CONST.TRANSLATION_KEYS.ATTACHMENT : '', @@ -3293,9 +3297,24 @@ function updateOptimisticParentReportAction(parentReportAction: OnyxEntry, reportPreviewAction: }; } -function buildOptimisticTaskReportAction(taskReportID: string, actionName: OriginalMessageActionName, message = ''): OptimisticTaskReportAction { +function buildOptimisticTaskReportAction( + taskReportID: string, + actionName: OriginalMessageActionName, + message = '', + actorAccountID = currentUserAccountID, + createdOffset = 0, +): OptimisticTaskReportAction { const originalMessage = { taskReportID, type: actionName, @@ -3887,7 +3932,7 @@ function buildOptimisticTaskReportAction(taskReportID: string, actionName: Origi }; return { actionName, - actorAccountID: currentUserAccountID, + actorAccountID, automatic: false, avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, @@ -3908,7 +3953,7 @@ function buildOptimisticTaskReportAction(taskReportID: string, actionName: Origi ], reportActionID: NumberUtils.rand64(), shouldShow: true, - created: DateUtils.getDBTime(), + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), isFirstItem: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; @@ -4363,6 +4408,7 @@ function buildOptimisticTaskReport( title?: string, description?: string, policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, + notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ): OptimisticTaskReport { // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same const participantsAccountIDs = assigneeAccountID && assigneeAccountID !== ownerAccountID ? [assigneeAccountID] : []; @@ -4380,7 +4426,7 @@ function buildOptimisticTaskReport( policyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + notificationPreference, lastVisibleActionCreated: DateUtils.getDBTime(), }; } diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 8248b721c416..b9cea5c9447c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -65,6 +65,23 @@ function updatePronouns(pronouns: string) { }); } +function setDisplayName(firstName: string, lastName: string) { + if (!currentUserAccountID) { + return; + } + + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [currentUserAccountID]: { + firstName, + lastName, + displayName: PersonalDetailsUtils.createDisplayName(currentUserEmail ?? '', { + firstName, + lastName, + }), + }, + }); +} + function updateDisplayName(firstName: string, lastName: string) { if (!currentUserAccountID) { return; @@ -411,6 +428,7 @@ export { updateAutomaticTimezone, updateAvatar, updateDateOfBirth, + setDisplayName, updateDisplayName, updateLegalName, updatePronouns, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 411cee718062..8b0dbf8a37a9 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -15,6 +15,7 @@ import type { AddEmojiReactionParams, AddWorkspaceRoomParams, CompleteEngagementModalParams, + CompleteGuidedSetupParams, DeleteCommentParams, ExpandURLPreviewParams, FlagCommentParams, @@ -55,6 +56,7 @@ import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import LocalNotification from '@libs/Notification/LocalNotification'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -71,6 +73,7 @@ import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import * as UserUtils from '@libs/UserUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; +import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -84,6 +87,7 @@ import type { PolicyReportField, QuickAction, RecentlyUsedReportFields, + ReportAction, ReportActionReactions, ReportMetadata, ReportUserIsTyping, @@ -92,7 +96,6 @@ import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage import type {NotificationPreference, Participants, Participant as ReportParticipant, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; -import type ReportAction from '@src/types/onyx/ReportAction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CachedPDFPaths from './CachedPDFPaths'; @@ -107,6 +110,42 @@ type ActionSubscriber = { callback: SubscriberCallback; }; +type Video = { + url: string; + thumbnailUrl: string; + duration: number; + width: number; + height: number; +}; + +type TaskMessage = Required>; + +type TaskForParameters = + | { + type: 'task'; + task: string; + taskReportID: string; + parentReportID: string; + parentReportActionID: string; + assigneeChatReportID: string; + createdTaskReportActionID: string; + completedTaskReportActionID?: string; + title: string; + description: string; + } + | ({ + type: 'message'; + } & TaskMessage); + +type GuidedSetupData = Array< + | ({type: 'message'} & AddCommentOrAttachementParams) + | TaskForParameters + | ({ + type: 'video'; + } & Video & + AddCommentOrAttachementParams) +>; + let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; let currentUserEmail: string | undefined; @@ -123,6 +162,14 @@ Onyx.connect({ }, }); +let guideCalendarLink: string | undefined; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + guideCalendarLink = value?.guideCalendarLink ?? undefined; + }, +}); + let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE; Onyx.connect({ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, @@ -791,6 +838,11 @@ function openReport( key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: settledPersonalDetails, }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: settledPersonalDetails, + }); // Add the createdReportActionID parameter to the API call parameters.createdReportActionID = optimisticCreatedAction.reportActionID; @@ -1983,16 +2035,6 @@ function deleteReport(reportID: string) { Onyx.multiSet(onyxData); - // Clear the optimistic personal detail - const participantPersonalDetails: OnyxCollection = {}; - report?.participantAccountIDs?.forEach((accountID) => { - if (!allPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { - return; - } - participantPersonalDetails[accountID] = null; - }); - Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantPersonalDetails); - // Delete linked IOU report if (report?.iouReportID) { deleteReport(report.iouReportID); @@ -2957,13 +2999,286 @@ function getReportPrivateNote(reportID: string | undefined) { API.read(READ_COMMANDS.GET_REPORT_PRIVATE_NOTE, parameters, {optimisticData, successData, failureData}); } +function completeOnboarding( + engagementChoice: OnboardingPurposeType, + data: ValueOf, + { + login, + firstName, + lastName, + }: { + login: string; + firstName: string; + lastName: string; + }, + adminsChatReportID?: string, +) { + const targetEmail = CONST.EMAIL.CONCIERGE; + const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; + const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID]); + const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; + + // Mention message + const mentionHandle = LoginUtils.isEmailPublicDomain(login) ? login : login.split('@')[0]; + const mentionComment = ReportUtils.buildOptimisticAddCommentReportAction(`Hey @${mentionHandle} 👋`, undefined, actorAccountID); + const mentionCommentAction: OptimisticAddCommentReportAction = mentionComment.reportAction; + const mentionMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: mentionCommentAction.reportActionID, + reportComment: mentionComment.commentText, + }; + + // Text message + const textComment = ReportUtils.buildOptimisticAddCommentReportAction(data.message, undefined, actorAccountID, 1); + const textCommentAction: OptimisticAddCommentReportAction = textComment.reportAction; + const textMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: textCommentAction.reportActionID, + reportComment: textComment.commentText, + }; + + // Video message + const videoComment = ReportUtils.buildOptimisticAddCommentReportAction(CONST.ATTACHMENT_MESSAGE_TEXT, undefined, actorAccountID, 2); + const videoCommentAction: OptimisticAddCommentReportAction = videoComment.reportAction; + const videoMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: videoCommentAction.reportActionID, + reportComment: videoComment.commentText, + }; + + const tasksData = data.tasks.map((task, index) => { + const currentTask = ReportUtils.buildOptimisticTaskReport( + actorAccountID, + undefined, + targetChatReportID, + task.title, + undefined, + targetChatPolicyID, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(targetEmail); + const taskReportAction = ReportUtils.buildOptimisticTaskCommentReportAction( + currentTask.reportID, + task.title, + 0, + `task for ${task.title}`, + targetChatReportID, + actorAccountID, + index + 3, + { + childVisibleActionCount: 2, + childCommenterCount: 1, + childLastVisibleActionCreated: DateUtils.getDBTime(), + childOldestFourAccountIDs: `${actorAccountID}`, + }, + ); + const subtitleComment = task.subtitle ? ReportUtils.buildOptimisticAddCommentReportAction(task.subtitle, undefined, actorAccountID) : null; + const isTaskMessageFunction = typeof task.message === 'function'; + const taskMessage = isTaskMessageFunction + ? task.message({ + adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '')}`, + guideCalendarLink: guideCalendarLink ?? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL, + }) + : task.message; + const instructionComment = ReportUtils.buildOptimisticAddCommentReportAction(taskMessage, undefined, actorAccountID, 1, isTaskMessageFunction ? undefined : false); + const completedTaskReportAction = task.autoCompleted + ? ReportUtils.buildOptimisticTaskReportAction(currentTask.reportID, CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, 'marked as complete', actorAccountID, 2) + : null; + + return { + task, + currentTask, + taskCreatedAction, + taskReportAction, + subtitleComment, + instructionComment, + completedTaskReportAction, + }; + }); + + const tasksForParameters = tasksData.reduce( + (acc, {task, currentTask, taskCreatedAction, taskReportAction, subtitleComment, instructionComment, completedTaskReportAction}) => { + const instructionCommentAction: OptimisticAddCommentReportAction = instructionComment.reportAction; + const instructionCommentText = instructionComment.commentText; + const instructionMessage: TaskMessage = { + reportID: currentTask.reportID, + reportActionID: instructionCommentAction.reportActionID, + reportComment: instructionCommentText, + }; + + const tasksForParametersAcc: TaskForParameters[] = [ + ...acc, + { + type: 'task', + task: task.type, + taskReportID: currentTask.reportID, + parentReportID: currentTask.parentReportID ?? '', + parentReportActionID: taskReportAction.reportAction.reportActionID, + assigneeChatReportID: '', + createdTaskReportActionID: taskCreatedAction.reportActionID, + completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? undefined, + title: currentTask.reportName ?? '', + description: currentTask.description ?? '', + }, + { + type: 'message', + ...instructionMessage, + }, + ]; + + if (subtitleComment) { + const subtitleCommentAction: OptimisticAddCommentReportAction = subtitleComment.reportAction; + const subtitleCommentText = subtitleComment.commentText; + const subtitleMessage: TaskMessage = { + reportID: currentTask.reportID, + reportActionID: subtitleCommentAction.reportActionID, + reportComment: subtitleCommentText, + }; + + tasksForParametersAcc.push({ + type: 'message', + ...subtitleMessage, + }); + } + + return tasksForParametersAcc; + }, + [], + ); + + const tasksForOptimisticData = tasksData.reduce( + (acc, {currentTask, taskCreatedAction, taskReportAction, subtitleComment, instructionComment, completedTaskReportAction}) => { + const instructionCommentAction: OptimisticAddCommentReportAction = instructionComment.reportAction; + + const tasksForOptimisticDataAcc: OnyxUpdate[] = [ + ...acc, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [taskReportAction.reportAction.reportActionID]: taskReportAction.reportAction as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + ...currentTask, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isOptimisticReport: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [taskCreatedAction.reportActionID]: taskCreatedAction as ReportAction, + [instructionCommentAction.reportActionID]: instructionCommentAction as ReportAction, + }, + }, + ]; + + if (subtitleComment) { + const subtitleCommentAction: OptimisticAddCommentReportAction = subtitleComment.reportAction; + + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [subtitleCommentAction.reportActionID]: subtitleCommentAction as ReportAction, + }, + }); + } + + if (completedTaskReportAction) { + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [completedTaskReportAction.reportActionID]: completedTaskReportAction as ReportAction, + }, + }); + + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }, + }); + } + + return tasksForOptimisticDataAcc; + }, + [], + ); + + const optimisticData: OnyxUpdate[] = [ + ...tasksForOptimisticData, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`, + value: { + lastMentionedTime: DateUtils.getDBTime(), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [mentionCommentAction.reportActionID]: mentionCommentAction as ReportAction, + [textCommentAction.reportActionID]: textCommentAction as ReportAction, + [videoCommentAction.reportActionID]: videoCommentAction as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice: engagementChoice}, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [mentionCommentAction.reportActionID]: {pendingAction: null}, + [textCommentAction.reportActionID]: {pendingAction: null}, + [videoCommentAction.reportActionID]: {pendingAction: null}, + }, + }, + ]; + + const guidedSetupData: GuidedSetupData = [ + {type: 'message', ...mentionMessage}, + {type: 'message', ...textMessage}, + {type: 'video', ...data.video, ...videoMessage}, + ...tasksForParameters, + ]; + + const parameters: CompleteGuidedSetupParams = { + engagementChoice, + firstName, + lastName, + guidedSetupData: JSON.stringify(guidedSetupData), + }; + + API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData}); +} + /** * Completes the engagement modal that new NewDot users see when they first sign up/log in by doing the following: * * - Sets the introSelected NVP to the choice the user made * - Creates an optimistic report comment from concierge */ -function completeEngagementModal(choice: ValueOf, text?: string) { +function completeEngagementModal(choice: OnboardingPurposeType, text?: string) { const conciergeAccountID = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE])[0]; // We do not need to send any message for some choices @@ -3349,6 +3664,7 @@ export { setGroupDraft, clearGroupChat, startNewChat, + completeOnboarding, updateGroupChatName, updateGroupChatAvatar, leaveGroupChat, diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 87762bc856ca..84066c05e80e 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -1,6 +1,6 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {SelectedPurposeType} from '@pages/OnboardingPurpose/BaseOnboardingPurpose'; +import type {OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxPolicy from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -112,7 +112,7 @@ function getPersonalDetails(accountID: number | undefined) { }); } -function setOnboardingPurposeSelected(value: SelectedPurposeType) { +function setOnboardingPurposeSelected(value: OnboardingPurposeType) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index dd5707ef3507..c7387c2b6aee 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -1,5 +1,6 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; @@ -8,30 +9,27 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; +import variables from '@styles/variables'; import * as PersonalDetails from '@userActions/PersonalDetails'; +import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; +import type {BaseOnboardingPersonalDetailsOnyxProps, BaseOnboardingPersonalDetailsProps} from './types'; -type BaseOnboardingPersonalDetailsProps = { - /* Whether to use native styles tailored for native devices */ - shouldUseNativeStyles: boolean; -}; +const OPEN_WORK_PAGE_PURPOSES = [CONST.ONBOARDING_CHOICES.TRACK, CONST.ONBOARDING_CHOICES.MANAGE_TEAM]; -function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles}: WithCurrentUserPersonalDetailsProps & BaseOnboardingPersonalDetailsProps) { - const theme = useTheme(); +function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected}: BaseOnboardingPersonalDetailsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -39,11 +37,47 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat useDisableModalDismissOnEscape(); - const saveAndNavigate = useCallback((values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { - PersonalDetails.updateDisplayName(values.firstName.trim(), values.lastName.trim()); + const completeEngagement = useCallback( + (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { + const firstName = values.firstName.trim(); + const lastName = values.lastName.trim(); - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE); - }, []); + PersonalDetails.setDisplayName(firstName, lastName); + + if (!onboardingPurposeSelected) { + return; + } + + if (OPEN_WORK_PAGE_PURPOSES.includes(onboardingPurposeSelected)) { + Navigation.navigate(ROUTES.ONBOARDING_WORK); + + return; + } + + Report.completeOnboarding(onboardingPurposeSelected, CONST.ONBOARDING_MESSAGES[onboardingPurposeSelected], { + login: currentUserPersonalDetails.login ?? '', + firstName, + lastName, + }); + + Navigation.dismissModal(); + + // Only navigate to concierge chat when central pane is visible + // Otherwise stay on the chats screen. + if (isSmallScreenWidth) { + Navigation.navigate(ROUTES.HOME); + } else { + Report.navigateToConciergeChat(); + } + + // Small delay purely due to design considerations, + // no special technical reasons behind that. + setTimeout(() => { + Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT); + }, variables.welcomeVideoDelay); + }, + [currentUserPersonalDetails.login, isSmallScreenWidth, onboardingPurposeSelected], + ); const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { const errors = {}; @@ -82,20 +116,20 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat return ( - {translate('onboarding.welcome')} {translate('onboarding.whatsYourName')} @@ -144,6 +177,10 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat BaseOnboardingPersonalDetails.displayName = 'BaseOnboardingPersonalDetails'; -export default withCurrentUserPersonalDetails(BaseOnboardingPersonalDetails); - -export type {BaseOnboardingPersonalDetailsProps}; +export default withCurrentUserPersonalDetails( + withOnyx({ + onboardingPurposeSelected: { + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + }, + })(BaseOnboardingPersonalDetails), +); diff --git a/src/pages/OnboardingPersonalDetails/index.native.tsx b/src/pages/OnboardingPersonalDetails/index.native.tsx index 3c49a13178e6..b5a4d42f0de1 100644 --- a/src/pages/OnboardingPersonalDetails/index.native.tsx +++ b/src/pages/OnboardingPersonalDetails/index.native.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import type {BaseOnboardingPersonalDetailsProps} from './BaseOnboardingPersonalDetails'; import BaseOnboardingPersonalDetails from './BaseOnboardingPersonalDetails'; +import type {OnboardingPersonalDetailsProps} from './types'; -function OnboardingPersonalDetails({...rest}: Omit) { +function OnboardingPersonalDetails({...rest}: Omit) { return ( ) { +function OnboardingPersonalDetails({...rest}: Omit) { return ( ; + +type BaseOnboardingPersonalDetailsOnyxProps = { + /** Saved onboarding purpose selected by the user */ + onboardingPurposeSelected: OnyxEntry; +}; + +type BaseOnboardingPersonalDetailsProps = WithCurrentUserPersonalDetailsProps & + BaseOnboardingPersonalDetailsOnyxProps & { + /* Whether to use native styles tailored for native devices */ + shouldUseNativeStyles: boolean; + }; + +export type {OnboardingPersonalDetailsProps, BaseOnboardingPersonalDetailsOnyxProps, BaseOnboardingPersonalDetailsProps}; diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 82f171e12f14..97a81af7e59d 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -20,16 +20,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; -import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; +import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps} from './types'; -type ValuesType = T[keyof T]; -type SelectedPurposeType = ValuesType | undefined; - const menuIcons = { [CONST.ONBOARDING_CHOICES.TRACK]: Illustrations.CompanyCard, [CONST.ONBOARDING_CHOICES.EMPLOYER]: Illustrations.ReceiptUpload, @@ -43,7 +40,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useOnboardingLayout(); - const [selectedPurpose, setSelectedPurpose] = useState(undefined); + const [selectedPurpose, setSelectedPurpose] = useState(undefined); const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); const [error, setError] = useState(false); const theme = useTheme(); @@ -62,10 +59,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on const paddingHorizontal = shouldUseNarrowLayout ? styles.ph8 : styles.ph5; - const handleGoBack = useCallback(() => { - Navigation.goBack(); - }, []); - const selectedCheckboxIcon = useMemo( () => ( @@ -78,28 +71,13 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on [styles.pointerEventsAuto, styles.popoverMenuIcon, theme.success], ); - const completeEngagement = useCallback(() => { + const saveAndNavigate = useCallback(() => { if (selectedPurpose === undefined) { return; } - Report.completeEngagementModal(selectedPurpose, CONST.ONBOARDING_CONCIERGE[selectedPurpose]); - - Navigation.dismissModal(); - // Only navigate to concierge chat when central pane is visible - // Otherwise stay on the chats screen. - if (isSmallScreenWidth) { - Navigation.navigate(ROUTES.HOME); - } else { - Report.navigateToConciergeChat(); - } - - // Small delay purely due to design considerations, - // no special technical reasons behind that. - setTimeout(() => { - Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT); - }, variables.welcomeVideoDelay); - }, [isSmallScreenWidth, selectedPurpose]); + Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS); + }, [selectedPurpose]); const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => { const translationKey = `onboarding.purpose.${choice}` as const; @@ -129,9 +107,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on @@ -155,7 +133,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on return; } setError(false); - completeEngagement(); + saveAndNavigate(); }} message={errorMessage} isAlertVisible={error || Boolean(errorMessage)} @@ -175,4 +153,4 @@ export default withOnyx; type BaseOnboardingPurposeOnyxProps = { /** Saved onboarding purpose selected by the user */ - onboardingPurposeSelected: OnyxEntry; + onboardingPurposeSelected: OnyxEntry; }; type BaseOnboardingPurposeProps = OnboardingPurposeProps & diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx new file mode 100644 index 000000000000..1236a8178796 --- /dev/null +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -0,0 +1,150 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; +import OfflineIndicator from '@components/OfflineIndicator'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; +import useLocalize from '@hooks/useLocalize'; +import useOnboardingLayout from '@hooks/useOnboardingLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import variables from '@styles/variables'; +import * as Policy from '@userActions/Policy'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/WorkForm'; +import type {BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps} from './types'; + +function BaseOnboardingWork({currentUserPersonalDetails, shouldUseNativeStyles, onboardingPurposeSelected}: BaseOnboardingWorkProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const {shouldUseNarrowLayout} = useOnboardingLayout(); + + useDisableModalDismissOnEscape(); + + const completeEngagement = useCallback( + (values: FormOnyxValues<'onboardingWorkForm'>) => { + if (!onboardingPurposeSelected) { + return; + } + + const work = values.work.trim(); + + const adminsChatReportID = Policy.createWorkspace(undefined, true, work); + + Report.completeOnboarding( + onboardingPurposeSelected, + CONST.ONBOARDING_MESSAGES[onboardingPurposeSelected], + { + login: currentUserPersonalDetails.login ?? '', + firstName: currentUserPersonalDetails.firstName ?? '', + lastName: currentUserPersonalDetails.lastName ?? '', + }, + adminsChatReportID, + ); + + Navigation.dismissModal(); + + // Only navigate to concierge chat when central pane is visible + // Otherwise stay on the chats screen. + if (isSmallScreenWidth) { + Navigation.navigate(ROUTES.HOME); + } else { + Report.navigateToConciergeChat(); + } + + // Small delay purely due to design considerations, + // no special technical reasons behind that. + setTimeout(() => { + Navigation.navigate(ROUTES.WELCOME_VIDEO_ROOT); + }, variables.welcomeVideoDelay); + }, + [currentUserPersonalDetails.firstName, currentUserPersonalDetails.lastName, currentUserPersonalDetails.login, isSmallScreenWidth, onboardingPurposeSelected], + ); + + const validate = (values: FormOnyxValues<'onboardingWorkForm'>) => { + const errors: FormInputErrors = {}; + const work = values.work.trim(); + + if (!ValidationUtils.isRequiredFulfilled(work)) { + errors.work = 'workspace.editor.nameIsRequiredError'; + } else if ([...work].length > CONST.TITLE_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + ErrorUtils.addErrorMessage(errors, 'work', ['common.error.characterLimitExceedCounter', {length: [...work].length, limit: CONST.TITLE_CHARACTER_LIMIT}]); + } + + return errors; + }; + + const WorkFooterInstance = ; + + return ( + + + + + + {translate('onboarding.whereYouWork')} + + + + + + + + ); +} + +BaseOnboardingWork.displayName = 'BaseOnboardingWork'; + +export default withCurrentUserPersonalDetails( + withOnyx({ + onboardingPurposeSelected: { + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + }, + })(BaseOnboardingWork), +); diff --git a/src/pages/OnboardingWork/index.native.tsx b/src/pages/OnboardingWork/index.native.tsx new file mode 100644 index 000000000000..3e69696aa45a --- /dev/null +++ b/src/pages/OnboardingWork/index.native.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import BaseOnboardingWork from './BaseOnboardingWork'; +import type {OnboardingWorkProps} from './types'; + +function OnboardingWork({...rest}: Omit) { + return ( + + ); +} + +OnboardingWork.displayName = 'OnboardingWork'; + +export default OnboardingWork; diff --git a/src/pages/OnboardingWork/index.tsx b/src/pages/OnboardingWork/index.tsx new file mode 100644 index 000000000000..ba1b8aaeb106 --- /dev/null +++ b/src/pages/OnboardingWork/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import BaseOnboardingWork from './BaseOnboardingWork'; +import type {OnboardingWorkProps} from './types'; + +function OnboardingWork({...rest}: Omit) { + return ( + + ); +} + +OnboardingWork.displayName = 'OnboardingPurpose'; + +export default OnboardingWork; diff --git a/src/pages/OnboardingWork/types.ts b/src/pages/OnboardingWork/types.ts new file mode 100644 index 000000000000..5bef8048628d --- /dev/null +++ b/src/pages/OnboardingWork/types.ts @@ -0,0 +1,18 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import type {OnboardingPurposeType} from '@src/CONST'; + +type OnboardingWorkProps = Record; + +type BaseOnboardingWorkOnyxProps = { + /** Saved onboarding purpose selected by the user */ + onboardingPurposeSelected: OnyxEntry; +}; + +type BaseOnboardingWorkProps = WithCurrentUserPersonalDetailsProps & + BaseOnboardingWorkOnyxProps & { + /* Whether to use native styles tailored for native devices */ + shouldUseNativeStyles: boolean; + }; + +export type {OnboardingWorkProps, BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps}; diff --git a/src/types/form/WorkForm.ts b/src/types/form/WorkForm.ts new file mode 100644 index 000000000000..6e5fa8a89311 --- /dev/null +++ b/src/types/form/WorkForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + WORK: 'work', +} as const; + +type InputID = ValueOf; + +type WorkForm = Form< + InputID, + { + [INPUT_IDS.WORK]: string; + } +>; + +export type {WorkForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index ce3fcd428999..ddddb99ab89b 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -48,4 +48,5 @@ export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName'; export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm'; export type {PolicyDistanceRateEditForm} from './PolicyDistanceRateEditForm'; export type {NewChatNameForm} from './NewChatNameForm'; +export type {WorkForm} from './WorkForm'; export type {default as Form} from './Form'; diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts index 9917f4b44550..14a0d2f70dfe 100644 --- a/src/types/onyx/IntroSelected.ts +++ b/src/types/onyx/IntroSelected.ts @@ -1,9 +1,8 @@ -import type {ValueOf} from 'type-fest'; -import type CONST from '@src/CONST'; +import type {OnboardingPurposeType} from '@src/CONST'; type IntroSelected = { /** The choice that the user selected in the engagement modal */ - choice: ValueOf; + choice: OnboardingPurposeType; }; export default IntroSelected;