diff --git a/assets/images/product-illustrations/mushroom-top-hat.svg b/assets/images/product-illustrations/mushroom-top-hat.svg new file mode 100644 index 000000000000..cb808f7289e0 --- /dev/null +++ b/assets/images/product-illustrations/mushroom-top-hat.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 6090b2b1c0d2..aab783e8bbb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -237,7 +237,7 @@ "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-node": "^10.9.2", - "type-fest": "^3.12.0", + "type-fest": "^4.10.2", "typescript": "^5.3.2", "wait-port": "^0.2.9", "webpack": "^5.76.0", @@ -8139,9 +8139,9 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", - "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", + "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", "dev": true, "dependencies": { "ansi-html-community": "^0.0.8", @@ -8161,7 +8161,7 @@ "@types/webpack": "4.x || 5.x", "react-refresh": ">=0.10.0 <1.0.0", "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <4.0.0", + "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", "webpack-dev-server": "3.x || 4.x", "webpack-hot-middleware": "2.x", @@ -26870,10 +26870,11 @@ } }, "node_modules/core-js-pure": { - "version": "3.24.1", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.36.0.tgz", + "integrity": "sha512-cN28qmhRNgbMZZMc/RFu5w8pK9VJzpb2rJVR/lHuZJKwmXnoWOpXmMkxqBB514igkp1Hu8WGROsiOAzUcKdHOQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -38622,6 +38623,17 @@ "node": ">=8" } }, + "node_modules/jest-watch-typeahead/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-watcher": { "version": "29.4.1", "license": "MIT", @@ -50849,11 +50861,12 @@ } }, "node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.3.tgz", + "integrity": "sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==", + "dev": true, "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index b99272e453b9..f5ff807cdbec 100644 --- a/package.json +++ b/package.json @@ -285,7 +285,7 @@ "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-node": "^10.9.2", - "type-fest": "^3.12.0", + "type-fest": "^4.10.2", "typescript": "^5.3.2", "wait-port": "^0.2.9", "webpack": "^5.76.0", diff --git a/src/CONST.ts b/src/CONST.ts index ce1295d2d71f..8abd4c087b16 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3311,6 +3311,14 @@ const CONST = { ADDRESS: 3, }, }, + + EXIT_SURVEY: { + REASONS: { + FEATURE_NOT_AVAILABLE: 'featureNotAvailable', + DONT_UNDERSTAND: 'dontUnderstand', + PREFER_CLASSIC: 'preferClassic', + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index afbcd768b465..f0b400687b12 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -205,6 +205,9 @@ const ONYXKEYS = { /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', + /** Is the user in the process of switching to OldDot? */ + IS_SWITCHING_TO_OLD_DOT: 'isSwitchingToOldDot', + /** Is the test tools modal open? */ IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', @@ -388,6 +391,10 @@ const ONYXKEYS = { REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm', PERSONAL_BANK_ACCOUNT_DRAFT: 'personalBankAccountFormDraft', + EXIT_SURVEY_REASON_FORM: 'exitSurveyReasonForm', + EXIT_SURVEY_REASON_FORM_DRAFT: 'exitSurveyReasonFormDraft', + EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm', + EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft', }, } as const; @@ -410,6 +417,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.RoomSettingsForm; [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm; [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm; + [ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM]: FormTypes.ExitSurveyReasonForm; + [ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM]: FormTypes.ExitSurveyResponseForm; [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm; [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm; @@ -534,6 +543,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; [ONYXKEYS.IS_LOADING_APP]: boolean; + [ONYXKEYS.IS_SWITCHING_TO_OLD_DOT]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 11ffd06e0808..a8786bda3ffb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -159,6 +159,17 @@ const ROUTES = { getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const, }, + SETTINGS_EXIT_SURVEY_REASON: 'settings/exit-survey/reason', + SETTINGS_EXIT_SURVEY_RESPONSE: { + route: 'settings/exit-survey/response', + getRoute: (reason?: ValueOf, backTo?: string) => + getUrlWithBackToParam(`settings/exit-survey/response${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`, backTo), + }, + SETTINGS_EXIT_SURVEY_CONFIRM: { + route: 'settings/exit-survey/confirm', + getRoute: (backTo?: string) => getUrlWithBackToParam('settings/exit-survey/confirm', backTo), + }, + KEYBOARD_SHORTCUTS: 'keyboard-shortcuts', NEW: 'new', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cdc22e9be69e..520895c89c98 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -80,6 +80,12 @@ const SCREENS = { REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', }, + + EXIT_SURVEY: { + REASON: 'Settings_ExitSurvey_Reason', + RESPONSE: 'Settings_ExitSurvey_Response', + CONFIRM: 'Settings_ExitSurvey_Confirm', + }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index ae98978ffcad..37d0f730c9e9 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -7,6 +7,7 @@ import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type CountrySelector from '@components/CountrySelector'; import type Picker from '@components/Picker'; +import type RadioButtons from '@components/RadioButtons'; import type SingleChoiceQuestion from '@components/SingleChoiceQuestion'; import type StatePicker from '@components/StatePicker'; import type TextInput from '@components/TextInput'; @@ -34,7 +35,8 @@ type ValidInputs = | typeof AmountForm | typeof BusinessTypePicker | typeof StatePicker - | typeof ValuePicker; + | typeof ValuePicker + | typeof RadioButtons; type ValueTypeKey = 'string' | 'boolean' | 'date'; type ValueTypeMap = { diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 9caa52bcc3bc..e03b393dc81f 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -16,6 +16,7 @@ import JewelBoxYellow from '@assets/images/product-illustrations/jewel-box--yell import MagicCode from '@assets/images/product-illustrations/magic-code.svg'; import MoneyEnvelopeBlue from '@assets/images/product-illustrations/money-envelope--blue.svg'; import MoneyMousePink from '@assets/images/product-illustrations/money-mouse--pink.svg'; +import MushroomTopHat from '@assets/images/product-illustrations/mushroom-top-hat.svg'; import PaymentHands from '@assets/images/product-illustrations/payment-hands.svg'; import ReceiptYellow from '@assets/images/product-illustrations/receipt--yellow.svg'; import ReceiptsSearchYellow from '@assets/images/product-illustrations/receipts-search--yellow.svg'; @@ -96,6 +97,7 @@ export { Mailbox, MoneyEnvelopeBlue, MoneyMousePink, + MushroomTopHat, ReceiptsSearchYellow, ReceiptYellow, ReceiptWrangler, diff --git a/src/components/RadioButtonWithLabel.tsx b/src/components/RadioButtonWithLabel.tsx index 52464a1453a1..cfcd6acba41f 100644 --- a/src/components/RadioButtonWithLabel.tsx +++ b/src/components/RadioButtonWithLabel.tsx @@ -55,7 +55,7 @@ function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = fal accessible={false} onPress={onPress} style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]} - wrapperStyle={[styles.ml3, styles.pr2, styles.w100]} + wrapperStyle={[styles.flex1, styles.ml3, styles.pr2]} // disable hover style when disabled hoverDimmingValue={0.8} pressDimmingValue={0.5} diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index 3407c5ad9afa..90c7d8580b5c 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -1,12 +1,16 @@ -import React, {useState} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import React, {forwardRef, useEffect, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; +import FormHelpMessage from './FormHelpMessage'; import RadioButtonWithLabel from './RadioButtonWithLabel'; type Choice = { label: string; value: string; + style?: StyleProp; }; type RadioButtonsProps = { @@ -19,33 +23,55 @@ type RadioButtonsProps = { /** Callback to fire when selecting a radio button */ onPress: (value: string) => void; + /** Potential error text provided by a form InputWrapper */ + errorText?: MaybePhraseKey; + /** Style for radio button */ radioButtonStyle?: StyleProp; + + /** Callback executed when input value changes (same as onPress, but required by FormProvider for the sake of saving drafts) */ + onInputChange?: (value: string) => void; + + /** The checked value, if you're using this component as a controlled input. */ + value?: string; }; -function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle}: RadioButtonsProps) { +function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle, errorText, onInputChange = () => {}, value}: RadioButtonsProps, ref: ForwardedRef) { const styles = useThemeStyles(); const [checkedValue, setCheckedValue] = useState(defaultCheckedValue); + useEffect(() => { + if (value === checkedValue) { + return; + } + setCheckedValue(value ?? ''); + }, [checkedValue, value]); return ( - - {items.map((item) => ( - { - setCheckedValue(item.value); - return onPress(item.value); - }} - label={item.label} - /> - ))} - + <> + + {items.map((item) => ( + { + setCheckedValue(item.value); + onInputChange(item.value); + return onPress(item.value); + }} + label={item.label} + /> + ))} + + {!!errorText && } + ); } RadioButtons.displayName = 'RadioButtons'; export type {Choice}; -export default RadioButtons; +export default forwardRef(RadioButtons); diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx index c8bf783032ad..3ff844dd80e9 100644 --- a/src/components/SingleChoiceQuestion.tsx +++ b/src/components/SingleChoiceQuestion.tsx @@ -4,7 +4,6 @@ import React, {forwardRef} from 'react'; import type {Text as RNText} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import type {MaybePhraseKey} from '@libs/Localize'; -import FormHelpMessage from './FormHelpMessage'; import type {Choice} from './RadioButtons'; import RadioButtons from './RadioButtons'; import Text from './Text'; @@ -32,8 +31,8 @@ function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuesti items={possibleAnswers} key={currentQuestionIndex} onPress={onInputChange} + errorText={errorText} /> - ); } diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index ce0f0e126252..a0f3d62c3547 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -51,15 +51,11 @@ type CustomBaseTextInputProps = { /** * Autogrow input container length based on the entered text. - * Note: If you use this prop, the text input has to be controlled - * by a value prop. */ autoGrow?: boolean; /** * Autogrow input container height based on the entered text - * Note: If you use this prop, the text input has to be controlled - * by a value prop. */ autoGrowHeight?: boolean; diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 74d10945fbcb..560576fdbf5c 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -8,27 +8,34 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; type KeyboardStateContextValue = { /** Whether the keyboard is open */ isKeyboardShown: boolean; + + /** Height of the keyboard in pixels */ + keyboardHeight: number; }; // TODO: Remove - left for backwards compatibility with existing components (https://github.com/Expensify/App/issues/25151) const keyboardStatePropTypes = { /** Whether the keyboard is open */ isKeyboardShown: PropTypes.bool.isRequired, + + /** Height of the keyboard in pixels */ + keyboardHeight: PropTypes.number.isRequired, }; const KeyboardStateContext = createContext({ isKeyboardShown: false, + keyboardHeight: 0, }); function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { - const [isKeyboardShown, setIsKeyboardShown] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { - setIsKeyboardShown(true); + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.endCoordinates.height); }); const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - setIsKeyboardShown(false); + setKeyboardHeight(0); }); return () => { @@ -39,9 +46,10 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { const contextValue = useMemo( () => ({ - isKeyboardShown, + keyboardHeight, + isKeyboardShown: keyboardHeight !== 0, }), - [isKeyboardShown], + [keyboardHeight], ); return {children}; } diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 1e4a6d4cf2ca..9d5e1e75d7c8 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -29,5 +29,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { prevOfflineStatusRef.current = isOffline; }, [isOffline]); - return {isOffline}; + return {isOffline: isOffline ?? false}; } diff --git a/src/languages/en.ts b/src/languages/en.ts index 1626419985b6..4d7041d4a791 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -860,7 +860,6 @@ export default { noLogsAvailable: 'No logs available', logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`, }, - goToExpensifyClassic: 'Go to Expensify Classic', security: 'Security', signOut: 'Sign out', signOutConfirmationText: "You'll lose any offline changes if you sign-out.", @@ -2379,4 +2378,28 @@ export default { mute: 'Mute', unmute: 'Unmute', }, + exitSurvey: { + header: 'Before you go', + reasonPage: { + title: "Please tell us why you're leaving", + subtitle: 'Before you go, please tell us why you’d like to switch to Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "I need a feature that's only available in Expensify Classic.", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: "I don't understand how to use New Expensify.", + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'I understand how to use New Expensify, but I prefer Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "What feature do you need that isn't available in New Expensify?", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'What are you trying to do?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Why do you prefer Expensify Classic?', + }, + responsePlaceholder: 'Your response', + thankYou: 'Thanks for the feedback!', + thankYouSubtitle: 'Your responses will help us build a better product to get stuff done. Thank you so much!', + goToExpensifyClassic: 'Switch to Expensify Classic', + offlineTitle: "Looks like you're stuck here...", + offline: + "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.", + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index d87d12e7f640..c9ff087d0de7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -859,7 +859,6 @@ export default { signOut: 'Desconectar', signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado', versionLetter: 'v', - goToExpensifyClassic: 'Ir a Expensify Classic', readTheTermsAndPrivacy: { phrase1: 'Leer los', phrase2: 'Términos de Servicio', @@ -2871,4 +2870,28 @@ export default { mute: 'Silenciar', unmute: 'Activar sonido', }, + exitSurvey: { + header: 'Antes de irte', + reasonPage: { + title: 'Dinos por qué te vas', + subtitle: 'Antes de irte, por favor dinos por qué te gustaría cambiarte a Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: 'Necesito una función que sólo está disponible en Expensify Classic.', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'No entiendo cómo usar New Expensify.', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Entiendo cómo usar New Expensify, pero prefiero Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: '¿Qué función necesitas que no esté disponible en New Expensify?', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: '¿Qué estás tratando de hacer?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: '¿Por qué prefieres Expensify Classic?', + }, + responsePlaceholder: 'Su respuesta', + thankYou: '¡Gracias por tus comentarios!', + thankYouSubtitle: 'Sus respuestas nos ayudarán a crear un mejor producto para hacer las cosas bien. ¡Muchas gracias!', + goToExpensifyClassic: 'Cambiar a Expensify Classic', + offlineTitle: 'Parece que estás atrapado aquí...', + offline: + 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.', + }, } satisfies EnglishTranslation; diff --git a/src/libs/API/parameters/SwitchToOldDotParams.ts b/src/libs/API/parameters/SwitchToOldDotParams.ts new file mode 100644 index 000000000000..95449a123dc9 --- /dev/null +++ b/src/libs/API/parameters/SwitchToOldDotParams.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type SwitchToOldDotParams = { + reason?: ValueOf; + surveyResponse?: string; +}; + +export default SwitchToOldDotParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 66c6692b19fb..0b0a81eb21f8 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -147,3 +147,4 @@ export type {default as AcceptACHContractForBankAccount} from './AcceptACHContra export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams'; export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; +export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 571fab3404f1..17cc366ba3b7 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -149,6 +149,7 @@ const WRITE_COMMANDS = { PAY_MONEY_REQUEST: 'PayMoneyRequest', CANCEL_PAYMENT: 'CancelPayment', ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount', + SWITCH_TO_OLD_DOT: 'SwitchToOldDot', } as const; type WriteCommand = ValueOf; @@ -296,6 +297,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams; [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING]: Parameters.SetWorkspaceAutoReportingParams; [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; + [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; }; const READ_COMMANDS = { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index cd75a6d31fdb..2be262aa5f0f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -253,6 +253,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: () => require('../../../pages/settings/Wallet/ReportCardLostPage').default as React.ComponentType, [SCREENS.KEYBOARD_SHORTCUTS]: () => require('../../../pages/KeyboardShortcutsPage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyReasonPage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyResponsePage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d98e19bb155e..48d649cc4dd9 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -273,6 +273,15 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.SHARE_CODE]: { path: ROUTES.SETTINGS_SHARE_CODE, }, + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_REASON, + }, + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.route, + }, + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2e00099b7966..f02bb3bd2aca 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -15,6 +15,7 @@ import type CONST from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; import type {HybridAppRoute, Route as Routes} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type EXIT_SURVEY_REASON_FORM_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; type NavigationRef = NavigationContainerRefWithCurrent; @@ -180,6 +181,14 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: undefined; [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: undefined; [SCREENS.KEYBOARD_SHORTCUTS]: undefined; + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: undefined; + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: { + [EXIT_SURVEY_REASON_FORM_INPUT_IDS.REASON]: ValueOf; + backTo: Routes; + }; + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: { + backTo: Routes; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index d7eb87a2ed1e..60e5246f5ed2 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -69,4 +69,11 @@ function parseFloatAnyLocale(value: string): number { return parseFloat(value ? value.replace(',', '.') : value); } -export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale}; +/** + * Given an input number p and another number q, returns the largest number that's less than p and divisible by q. + */ +function roundDownToLargestMultiple(p: number, q: number) { + return Math.floor(p / q) * q; +} + +export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple}; diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts new file mode 100644 index 000000000000..ef3ecd6d3e31 --- /dev/null +++ b/src/libs/actions/ExitSurvey.ts @@ -0,0 +1,78 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import ONYXKEYS from '@src/ONYXKEYS'; +import REASON_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; +import type {ExitReason} from '@src/types/form/ExitSurveyReasonForm'; +import RESPONSE_INPUT_IDS from '@src/types/form/ExitSurveyResponseForm'; + +let exitReason: ExitReason | undefined; +let exitSurveyResponse: string | undefined; +Onyx.connect({ + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, + callback: (value) => (exitReason = value?.[REASON_INPUT_IDS.REASON]), +}); +Onyx.connect({ + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, + callback: (value) => (exitSurveyResponse = value?.[RESPONSE_INPUT_IDS.RESPONSE]), +}); + +function saveExitReason(reason: ExitReason) { + Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, {[REASON_INPUT_IDS.REASON]: reason}); +} + +function saveResponse(response: string) { + Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, {[RESPONSE_INPUT_IDS.RESPONSE]: response}); +} + +/** + * Save the user's response to the mandatory exit survey in the back-end. + */ +function switchToOldDot() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT, + value: true, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT, + value: false, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM_DRAFT, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM_DRAFT, + value: null, + }, + ]; + + API.write( + 'SwitchToOldDot', + { + reason: exitReason, + surveyResponse: exitSurveyResponse, + }, + {optimisticData, finallyData}, + ); +} + +export {saveExitReason, saveResponse, switchToOldDot}; diff --git a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx new file mode 100644 index 000000000000..7459819afd99 --- /dev/null +++ b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx @@ -0,0 +1,107 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useEffect} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Icon from '@components//Icon'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {MushroomTopHat} from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import variables from '@styles/variables'; +import * as ExitSurvey from '@userActions/ExitSurvey'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {ExitReason, ExitSurveyReasonForm} from '@src/types/form/ExitSurveyReasonForm'; +import EXIT_SURVEY_REASON_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; +import ExitSurveyOffline from './ExitSurveyOffline'; + +type ExitSurveyConfirmPageOnyxProps = { + exitReason?: ExitReason; + isLoading: OnyxEntry; +}; + +type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenProps; + +function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + + const getBackToParam = useCallback(() => { + if (isOffline) { + return ROUTES.SETTINGS; + } + if (exitReason) { + return ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.getRoute(exitReason, ROUTES.SETTINGS_EXIT_SURVEY_REASON); + } + return ROUTES.SETTINGS; + }, [exitReason, isOffline]); + const {backTo} = route.params; + useEffect(() => { + const newBackTo = getBackToParam(); + if (backTo === newBackTo) { + return; + } + navigation.setParams({ + backTo: newBackTo, + }); + }, [backTo, getBackToParam, navigation]); + + return ( + + Navigation.goBack(backTo)} + /> + + {isOffline && } + {!isOffline && ( + <> + + {translate('exitSurvey.thankYou')} + {translate('exitSurvey.thankYouSubtitle')} + + )} + + +