From c0874ec26e04d78203fefdfdd5e715fdc03b2cbc Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 2 Apr 2025 12:10:36 +0700 Subject: [PATCH 01/12] create pay and downgrade rhp --- src/ONYXKEYS.ts | 12 ++ src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + src/components/PopoverMenu.tsx | 3 + src/components/ThreeDotsMenu/index.tsx | 14 +- src/components/ThreeDotsMenu/types.ts | 3 + src/hooks/usePayAndDowngrade.ts | 33 +++++ src/languages/en.ts | 9 ++ src/languages/es.ts | 9 ++ src/libs/API/types.ts | 5 + .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + src/libs/SubscriptionUtils.ts | 14 ++ src/libs/actions/Policy/Policy.ts | 71 ++++++++++ src/pages/workspace/WorkspaceOverviewPage.tsx | 28 +++- src/pages/workspace/WorkspacesListPage.tsx | 38 +++++- src/pages/workspace/WorkspacesListRow.tsx | 22 ++- .../downgrade/PayAndDowngradePage.tsx | 127 ++++++++++++++++++ src/types/onyx/BillingReceiptDetails.ts | 61 +++++++++ src/types/onyx/index.ts | 2 + 21 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 src/hooks/usePayAndDowngrade.ts create mode 100644 src/pages/workspace/downgrade/PayAndDowngradePage.tsx create mode 100644 src/types/onyx/BillingReceiptDetails.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f4c984a99b85..a2df7104d194 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -491,6 +491,15 @@ const ONYXKEYS = { /** Information about loading states while talking with AI sales */ TALK_TO_AI_SALES: 'talkToAISales', + /** Set when we are loading bill when downgrade */ + IS_LOADING_BILL_WHEN_DOWNGRADE: 'isLoadingBillWhenDowngrade', + + /** Should bill when downgrade */ + SHOULD_BILL_WHEN_DOWNGRADING: 'shouldBillWhenDowngrading', + + /** Billing receipt details */ + BILLING_RECEIPT_DETAILS: 'billingReceiptDetails', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1097,6 +1106,9 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; [ONYXKEYS.NVP_SIDE_PANE]: OnyxTypes.SidePane; + [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean; + [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean; + [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; }; type OnyxDerivedValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 92c9a3650b5a..dfcfb19c9dea 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1268,6 +1268,10 @@ const ROUTES = { getRoute: (policyID?: string, backTo?: string) => getUrlWithBackToParam(policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : (`settings/workspaces/downgrade` as const), backTo), }, + WORKSPACE_PAY_AND_DOWNGRADE: { + route: 'settings/workspaces/pay-and-downgrade/', + getRoute: (backTo?: string) => getUrlWithBackToParam(`settings/workspaces/pay-and-downgrade` as const, backTo), + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 01a83f520507..e8339c00e635 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -577,6 +577,7 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', DOWNGRADE: 'Workspace_Downgrade', + PAY_AND_DOWNGRADE: 'Workspace_Pay_And_Downgrade', RULES: 'Policy_Rules', RULES_CUSTOM_NAME: 'Rules_Custom_Name', RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under', diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 96fe02d69ca2..1b8711f27f19 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -61,6 +61,9 @@ type PopoverMenuItem = MenuItemProps & { rightIcon?: React.FC; key?: string; + + /** Whether to keep the modal open after clicking on the menu item */ + shouldKeepModalOpen?: boolean; }; type PopoverModalProps = Pick & diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 79528b02c56d..6183c01504bd 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,9 +1,10 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; @@ -36,6 +37,7 @@ function ThreeDotsMenu({ renderProductTrainingTooltipContent, shouldShowProductTrainingTooltip = false, isNested = false, + threeDotsMenuRef, }: ThreeDotsMenuProps) { const [modal] = useOnyx(ONYXKEYS.MODAL); @@ -50,10 +52,18 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = () => { + const hidePopoverMenu = (selectedItem?: PopoverMenuItem) => { + if (selectedItem && selectedItem.shouldKeepModalOpen) { + return; + } setPopupMenuVisible(false); }; + useImperativeHandle(threeDotsMenuRef, () => ({ + isPopupMenuVisible, + hidePopoverMenu, + })); + useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts index 28ce20da6da9..8e27a99b36df 100644 --- a/src/components/ThreeDotsMenu/types.ts +++ b/src/components/ThreeDotsMenu/types.ts @@ -50,6 +50,9 @@ type ThreeDotsMenuProps = { /** Is the menu nested? This prop is used to omit html warning when we are nesting a button inside another button */ isNested?: boolean; + + /** Ref to the menu */ + threeDotsMenuRef?: React.RefObject<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>; }; type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; diff --git a/src/hooks/usePayAndDowngrade.ts b/src/hooks/usePayAndDowngrade.ts new file mode 100644 index 000000000000..8fe165e27c8a --- /dev/null +++ b/src/hooks/usePayAndDowngrade.ts @@ -0,0 +1,33 @@ +import {useEffect, useRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +function usePayAndDowngrade(setIsDeleteModalOpen: (value: boolean) => void) { + const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); + const [shouldBillWhenDowngrading] = useOnyx(ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING); + const isDeletingPaidWorkspaceRef = useRef(false); + + const setIsDeletingPaidWorkspace = (value: boolean) => { + isDeletingPaidWorkspaceRef.current = value; + }; + + useEffect(() => { + if (!isDeletingPaidWorkspaceRef.current || isLoadingBill) { + return; + } + + if (!shouldBillWhenDowngrading) { + setIsDeleteModalOpen(true); + } else { + Navigation.navigate(ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.getRoute(Navigation.getActiveRoute())); + } + + isDeletingPaidWorkspaceRef.current = false; + }, [isLoadingBill, shouldBillWhenDowngrading, setIsDeleteModalOpen]); + + return {setIsDeletingPaidWorkspace, isLoadingBill}; +} + +export default usePayAndDowngrade; diff --git a/src/languages/en.ts b/src/languages/en.ts index 6cc541720079..b47fb93bbbde 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4657,6 +4657,15 @@ const translations = { gotIt: 'Got it, thanks', }, }, + payAndDowngrade: { + title: 'Pay & downgrade', + headline: 'Your final payment', + description1: 'Your final bill for this subscription will be', + description2: ({date}: DateParams) => `See your breakdown below for ${date}:`, + subscription: + 'This will end your subscription with Expensify, delete your remaining workspace and all members will lose access moving forward. If you want to remove just yourself, have another admin take over billing, and at that point, you can remove yourself from this workspace.', + genericFailureMessage: 'An error occurred while paying your bill. Please try again.', + }, restrictedAction: { restricted: 'Restricted', actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Actions on the ${workspaceName} workspace are currently restricted`, diff --git a/src/languages/es.ts b/src/languages/es.ts index de5a4dd6ce9b..6cffdd1478a0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4726,6 +4726,15 @@ const translations = { gotIt: 'Entendido, gracias.', }, }, + payAndDowngrade: { + title: 'Pagar y degradar', + headline: 'Tu pago final', + description1: 'Tu factura final para esta suscripción será', + description2: ({date}: DateParams) => `Consulta tu desglose a continuación para ${date}:`, + subscription: + 'Esto finalizará tu suscripción con Expensify, eliminará tu espacio de trabajo restante y todos los miembros perderán acceso en adelante. Si solo quieres eliminarte a ti mismo, otro administrador debe asumir la facturación, y en ese momento podrás eliminarte de este espacio de trabajo.', + genericFailureMessage: 'Ocurrió un error al pagar tu factura. Por favor, inténtalo de nuevo.', + }, restrictedAction: { restricted: 'Restringido', actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 09af8c9f03a7..c46f9dee70b2 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -461,6 +461,7 @@ const WRITE_COMMANDS = { SAVE_CORPAY_ONBOARDING_BENEFICIAL_OWNER: 'SaveCorpayOnboardingBeneficialOwner', CHANGE_REPORT_POLICY: 'ChangeReportPolicy', SEND_RECAP_IN_ADMINS_ROOM: 'SendRecapInAdminsRoom', + PAY_AND_DOWNGRADE: 'PayAndDowngrade', } as const; type WriteCommand = ValueOf; @@ -935,6 +936,8 @@ type WriteCommandParameters = { // Change report policy [WRITE_COMMANDS.CHANGE_REPORT_POLICY]: Parameters.ChangeReportPolicyParams; + + [WRITE_COMMANDS.PAY_AND_DOWNGRADE]: null; }; const READ_COMMANDS = { @@ -1004,6 +1007,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_PLAN_PAGE: 'OpenWorkspacePlanPage', GET_CORPAY_ONBOARDING_FIELDS: 'GetCorpayOnboardingFields', OPEN_SECURITY_SETTINGS_PAGE: 'OpenSecuritySettingsPage', + CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot', } as const; type ReadCommand = ValueOf; @@ -1075,6 +1079,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE]: Parameters.OpenWorkspacePlanPageParams; [READ_COMMANDS.GET_CORPAY_ONBOARDING_FIELDS]: Parameters.GetCorpayOnboardingFieldsParams; [READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE]: null; + [READ_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8e4dd26974f4..a3f9ca77d5e0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -309,6 +309,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/ImportedCategoriesPage').default, [SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default, [SCREENS.WORKSPACE.DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/WorkspaceDowngradePage').default, + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/PayAndDowngradePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default, [SCREENS.WORKSPACE.MEMBER_NEW_CARD]: () => require('../../../../pages/workspace/members/WorkspaceMemberNewCardPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 4c2ec248eb81..4141ab43f45c 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -652,6 +652,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DOWNGRADE]: { path: ROUTES.WORKSPACE_DOWNGRADE.route, }, + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: { + path: ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.route, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 218ddf1e0f33..37a20b397425 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -225,6 +225,9 @@ type SettingsNavigatorParamList = { policyID?: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: { + policyID?: string; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; backTo?: Routes; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index bf188de6e189..2b46a338c3ac 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -146,6 +146,15 @@ Onyx.connect({ waitForCollectionCallback: true, }); +// Check if the user can downgrade +let canDowngrade = false; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (val) => { + canDowngrade = val?.canDowngrade ?? false; + }, +}); + /** * @returns The date when the grace period ends. */ @@ -538,6 +547,10 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { return false; } +function shouldCalculateBillNewDot(): boolean { + return canDowngrade && getOwnedPaidPolicies(allPolicies, currentUserAccountID).length === 1; +} + export { calculateRemainingFreeTrialDays, doesUserHavePaymentCardAdded, @@ -557,4 +570,5 @@ export { shouldShowPreTrialBillingBanner, shouldShowDiscountBanner, getEarlyDiscountInfo, + shouldCalculateBillNewDot, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 601dd3f91f32..8737fff5ffa5 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5061,6 +5061,74 @@ function getAssignedSupportData(policyID: string) { API.read(READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA, parameters); } +function calculateBillNewDot() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: true, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: false, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: false, + }, + ]; + API.read(READ_COMMANDS.CALCULATE_BILL_NEW_DOT, null, { + optimisticData, + successData, + failureData, + }); +} + +function payAndDowngrade() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + errors: null, + isLoading: true, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + errors: null, + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.payAndDowngrade.genericFailureMessage'), + isLoading: false, + }, + }, + ]; + API.write(WRITE_COMMANDS.PAY_AND_DOWNGRADE, null, {optimisticData, successData, failureData}); +} + +function clearBillingReceiptDetailsErrors() { + Onyx.merge(ONYXKEYS.BILLING_RECEIPT_DETAILS, {errors: null}); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5161,6 +5229,9 @@ export { updateDefaultPolicy, getAssignedSupportData, downgradeToTeam, + calculateBillNewDot, + payAndDowngrade, + clearBillingReceiptDetailsErrors, }; export type {NewCustomUnit}; diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 454cbb766f4c..cd42f91f4a78 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -15,12 +15,21 @@ import Section from '@components/Section'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearInviteDraft} from '@libs/actions/Policy/Member'; -import {clearAvatarErrors, clearPolicyErrorField, deleteWorkspace, deleteWorkspaceAvatar, openPolicyProfilePage, updateWorkspaceAvatar} from '@libs/actions/Policy/Policy'; +import { + calculateBillNewDot, + clearAvatarErrors, + clearPolicyErrorField, + deleteWorkspace, + deleteWorkspaceAvatar, + openPolicyProfilePage, + updateWorkspaceAvatar, +} from '@libs/actions/Policy/Policy'; import {filterInactiveCards} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import resetPolicyIDInNavigationState from '@libs/Navigation/helpers/resetPolicyIDInNavigationState'; @@ -30,6 +39,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getUserFriendlyWorkspaceType, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; +import {shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; import {getFullSizeAvatar} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -154,6 +164,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const {setIsDeletingPaidWorkspace, isLoadingBill} = usePayAndDowngrade(setIsDeleteModalOpen); + const confirmDeleteAndHideModal = useCallback(() => { if (!policy?.id || !policyName) { return; @@ -169,6 +181,16 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa } }, [policy?.id, policyName, activeWorkspaceID, setActiveWorkspaceID]); + const onDeleteWorkspace = useCallback(() => { + if (shouldCalculateBillNewDot()) { + setIsDeletingPaidWorkspace(true); + calculateBillNewDot(); + return; + } + + setIsDeleteModalOpen(true); + }, [setIsDeletingPaidWorkspace]); + return ( setIsDeleteModalOpen(true)} + onPress={onDeleteWorkspace} icon={Expensicons.Trashcan} + isLoading={isLoadingBill} + iconStyles={isLoadingBill ? styles.opacity0 : undefined} /> )} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 300be10e1161..7bcc163fbe7e 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -27,11 +27,12 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useHandleBackButton from '@hooks/useHandleBackButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import {clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; +import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -42,6 +43,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -125,12 +127,17 @@ function WorkspacesListPage() { const [session] = useOnyx(ONYXKEYS.SESSION); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); const [policyNameToDelete, setPolicyNameToDelete] = useState(); + const {setIsDeletingPaidWorkspace, isLoadingBill} = usePayAndDowngrade(setIsDeleteModalOpen); + + const [shouldShowLoadingSpinnerIcon, setShouldShowLoadingSpinnerIcon] = useState(false); + const isLessThanMediumScreen = isMediumScreenWidth || shouldUseNarrowLayout; // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. @@ -164,6 +171,15 @@ function WorkspacesListPage() { } }; + const shouldCalculateBillNewDot = shouldCalculateBillNewDotFn(); + + useEffect(() => { + if (!isLoadingBill) { + return; + } + setShouldShowLoadingSpinnerIcon(true); + }, [isLoadingBill]); + /** * Gets the menu item for each workspace */ @@ -187,16 +203,26 @@ function WorkspacesListPage() { threeDotsMenuItems.push({ icon: Expensicons.Trashcan, text: translate('workspace.common.delete'), + shouldShowLoadingSpinnerIcon, onSelected: () => { if (isSupportalAction) { setIsSupportalActionRestrictedModalOpen(true); return; } + setPolicyIDToDelete(item.policyID); setPolicyNameToDelete(item.title); + + if (shouldCalculateBillNewDot) { + setIsDeletingPaidWorkspace(true); + calculateBillNewDot(); + return; + } + setIsDeleteModalOpen(true); }, - shouldCallAfterModalHide: true, + shouldKeepModalOpen: shouldCalculateBillNewDot, + shouldCallAfterModalHide: !shouldCalculateBillNewDot, }); } @@ -263,6 +289,8 @@ function WorkspacesListPage() { shouldDisableThreeDotsMenu={item.disabled} style={[item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.offlineFeedback.deleted : {}]} isDefault={isDefault} + isLoadingBill={isLoadingBill} + onHideModal={() => setShouldShowLoadingSpinnerIcon(false)} /> )} @@ -281,6 +309,10 @@ function WorkspacesListPage() { session?.email, activePolicyID, isSupportalAction, + setIsDeletingPaidWorkspace, + isLoadingBill, + shouldCalculateBillNewDot, + shouldShowLoadingSpinnerIcon, ], ); diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index 54f8adc4af26..28f5486f258c 100644 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -71,6 +71,12 @@ type WorkspacesListRowProps = WithCurrentUserPersonalDetailsProps & { /** is policy defualt */ isDefault?: boolean; + + /** Whether the bill is loading */ + isLoadingBill?: boolean; + + /** Function to call when the modal is hidden */ + onHideModal?: () => void; }; type BrickRoadIndicatorIconProps = { @@ -115,6 +121,8 @@ function WorkspacesListRow({ isJoinRequestPending, policyID, isDefault, + isLoadingBill, + onHideModal, }: WorkspacesListRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -123,6 +131,17 @@ function WorkspacesListRow({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const ownerDetails = ownerAccountID && getPersonalDetailsByIDs({accountIDs: [ownerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); + const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>(null); + + useEffect(() => { + if (!!isLoadingBill || !threeDotsMenuRef.current?.isPopupMenuVisible) { + return; + } + threeDotsMenuRef?.current?.hidePopoverMenu(); + onHideModal?.(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingBill]); if (layoutWidth === CONST.LAYOUT_WIDTH.NONE) { // To prevent layout from jumping or rendering for a split second, when @@ -189,6 +208,7 @@ function WorkspacesListRow({ shouldOverlay disabled={shouldDisableThreeDotsMenu} isNested + threeDotsMenuRef={threeDotsMenuRef} /> diff --git a/src/pages/workspace/downgrade/PayAndDowngradePage.tsx b/src/pages/workspace/downgrade/PayAndDowngradePage.tsx new file mode 100644 index 000000000000..b142bf2715ac --- /dev/null +++ b/src/pages/workspace/downgrade/PayAndDowngradePage.tsx @@ -0,0 +1,127 @@ +import React, {useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {clearBillingReceiptDetailsErrors, payAndDowngrade} from '@src/libs/actions/Policy/Policy'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type BillingItem = { + key: string; + value: string; + isTotal: boolean; +}; + +function PayAndDowngradePage() { + const styles = useThemeStyles(); + + const {translate} = useLocalize(); + + const [billingDetails] = useOnyx(ONYXKEYS.BILLING_RECEIPT_DETAILS); + const prevIsLoading = usePrevious(billingDetails?.isLoading); + + const hasError = !!billingDetails?.errors; + + const items: BillingItem[] = useMemo(() => { + if (isEmptyObject(billingDetails)) { + return []; + } + const results = [...billingDetails.receiptsWithoutDiscount, ...billingDetails.discounts].map((item) => { + return { + key: item.description, + value: item.formattedAmount, + isTotal: false, + }; + }); + + results.push({ + key: translate('common.total'), + value: billingDetails.formattedSubtotal, + isTotal: true, + }); + return results; + }, [billingDetails, translate]); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (billingDetails?.isLoading || !prevIsLoading || billingDetails?.errors) { + return; + } + Navigation.dismissModal(); + }, [billingDetails?.isLoading, prevIsLoading, billingDetails?.errors]); + + useEffect(() => { + clearBillingReceiptDetailsErrors(); + }, []); + + return ( + + + + + + {translate('workspace.payAndDowngrade.headline')} + + {translate('workspace.payAndDowngrade.description1')} {billingDetails?.formattedSubtotal} + + + {translate('workspace.payAndDowngrade.description2', { + date: billingDetails?.billingMonth ?? '', + })} + + + + {items.map((item) => ( + + {!item.isTotal ? : {item.key}} + {item.value} + + ))} + + {translate('workspace.payAndDowngrade.subscription')} + + + {hasError && ( + + + + )} +