Skip to content

Commit c137167

Browse files
Merge pull request #55852 from Expensify/cristi_fix-bookATrip-action
Refactor bookATrip function
2 parents f22c07b + b851e2f commit c137167

File tree

10 files changed

+198
-229
lines changed

10 files changed

+198
-229
lines changed

src/components/BookTravelButton.tsx

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {Str} from 'expensify-common';
2+
import React, {useCallback, useContext, useState} from 'react';
3+
import {NativeModules} from 'react-native';
4+
import {useOnyx} from 'react-native-onyx';
5+
import useLocalize from '@hooks/useLocalize';
6+
import usePolicy from '@hooks/usePolicy';
7+
import useThemeStyles from '@hooks/useThemeStyles';
8+
import {openTravelDotLink} from '@libs/actions/Link';
9+
import {cleanupTravelProvisioningSession} from '@libs/actions/Travel';
10+
import Log from '@libs/Log';
11+
import Navigation from '@libs/Navigation/Navigation';
12+
import {getAdminsPrivateEmailDomains} from '@libs/PolicyUtils';
13+
import CONST from '@src/CONST';
14+
import ONYXKEYS from '@src/ONYXKEYS';
15+
import ROUTES from '@src/ROUTES';
16+
import {isEmptyObject} from '@src/types/utils/EmptyObject';
17+
import Button from './Button';
18+
import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext';
19+
import DotIndicatorMessage from './DotIndicatorMessage';
20+
21+
type BookTravelButtonProps = {
22+
text: string;
23+
};
24+
25+
const navigateToAcceptTerms = (domain: string) => {
26+
// Remove the previous provision session infromation if any is cached.
27+
cleanupTravelProvisioningSession();
28+
Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(domain));
29+
};
30+
31+
function BookTravelButton({text}: BookTravelButtonProps) {
32+
const styles = useThemeStyles();
33+
const {translate} = useLocalize();
34+
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
35+
const policy = usePolicy(activePolicyID);
36+
const [errorMessage, setErrorMessage] = useState('');
37+
const [travelSettings] = useOnyx(ONYXKEYS.NVP_TRAVEL_SETTINGS);
38+
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
39+
const primaryLogin = account?.primaryLogin;
40+
const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext);
41+
42+
// Flag indicating whether NewDot was launched exclusively for Travel,
43+
// e.g., when the user selects "Trips" from the Expensify Classic menu in HybridApp.
44+
const [wasNewDotLaunchedJustForTravel] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY);
45+
46+
const bookATrip = useCallback(() => {
47+
setErrorMessage('');
48+
49+
// The primary login of the user is where Spotnana sends the emails with booking confirmations, itinerary etc. It can't be a phone number.
50+
if (!primaryLogin || Str.isSMSLogin(primaryLogin)) {
51+
setErrorMessage(translate('travel.phoneError'));
52+
return;
53+
}
54+
55+
// Spotnana requires an address anytime an entity is created for a policy
56+
if (isEmptyObject(policy?.address)) {
57+
Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(policy?.id, Navigation.getActiveRoute()));
58+
return;
59+
}
60+
61+
const isPolicyProvisioned = policy?.travelSettings?.spotnanaCompanyID ?? policy?.travelSettings?.associatedTravelDomainAccountID;
62+
if (policy?.travelSettings?.hasAcceptedTerms ?? (travelSettings?.hasAcceptedTerms && isPolicyProvisioned)) {
63+
openTravelDotLink(policy?.id)
64+
?.then(() => {
65+
// When a user selects "Trips" in the Expensify Classic menu, the HybridApp opens the ManageTrips page in NewDot.
66+
// The wasNewDotLaunchedJustForTravel flag indicates if NewDot was launched solely for this purpose.
67+
if (!NativeModules.HybridAppModule || !wasNewDotLaunchedJustForTravel) {
68+
return;
69+
}
70+
71+
// Close NewDot if it was opened only for Travel, as its purpose is now fulfilled.
72+
Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
73+
NativeModules.HybridAppModule.closeReactNativeApp(false, false);
74+
setRootStatusBarEnabled(false);
75+
})
76+
?.catch(() => {
77+
setErrorMessage(translate('travel.errorMessage'));
78+
});
79+
} else if (isPolicyProvisioned) {
80+
navigateToAcceptTerms(CONST.TRAVEL.DEFAULT_DOMAIN);
81+
} else {
82+
// Determine the domain to associate with the workspace during provisioning in Spotnana.
83+
// - If all admins share the same private domain, the workspace is tied to it automatically.
84+
// - If admins have multiple private domains, the user must select one.
85+
// - Public domains are not allowed; an error page is shown in that case.
86+
const adminDomains = getAdminsPrivateEmailDomains(policy);
87+
if (adminDomains.length === 0) {
88+
Navigation.navigate(ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR);
89+
} else if (adminDomains.length === 1) {
90+
navigateToAcceptTerms(adminDomains.at(0) ?? CONST.TRAVEL.DEFAULT_DOMAIN);
91+
} else {
92+
Navigation.navigate(ROUTES.TRAVEL_DOMAIN_SELECTOR);
93+
}
94+
}
95+
}, [policy, wasNewDotLaunchedJustForTravel, travelSettings, translate, primaryLogin, setRootStatusBarEnabled]);
96+
97+
return (
98+
<>
99+
{!!errorMessage && (
100+
<DotIndicatorMessage
101+
style={styles.mb1}
102+
messages={{error: errorMessage}}
103+
type="error"
104+
/>
105+
)}
106+
<Button
107+
text={text}
108+
onPress={bookATrip}
109+
accessibilityLabel={translate('travel.bookTravel')}
110+
style={styles.w100}
111+
success
112+
large
113+
/>
114+
</>
115+
);
116+
}
117+
118+
BookTravelButton.displayName = 'BookTravelButton';
119+
120+
export default BookTravelButton;

src/components/EmptyStateComponent/index.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function EmptyStateComponent({
2323
title,
2424
titleStyles,
2525
subtitle,
26+
children,
2627
headerStyles,
2728
headerContentStyles,
2829
lottieWebViewStyles,
@@ -99,7 +100,8 @@ function EmptyStateComponent({
99100
<View style={[styles.emptyStateHeader(headerMediaType === CONST.EMPTY_STATE_MEDIA.ILLUSTRATION), headerStyles]}>{HeaderComponent}</View>
100101
<View style={shouldUseNarrowLayout ? styles.p5 : styles.p8}>
101102
<Text style={[styles.textAlignCenter, styles.textHeadlineH1, styles.mb2, titleStyles]}>{title}</Text>
102-
{typeof subtitle === 'string' ? <Text style={[styles.textAlignCenter, styles.textSupporting, styles.textNormal]}>{subtitle}</Text> : subtitle}
103+
<Text style={[styles.textAlignCenter, styles.textSupporting, styles.textNormal]}>{subtitle}</Text>
104+
{children}
103105
<View style={[styles.gap2, styles.mt5, !shouldUseNarrowLayout ? styles.flexRow : undefined]}>
104106
{buttons?.map(({buttonText, buttonAction, success, icon, isDisabled}, index) => (
105107
<View

src/components/EmptyStateComponent/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ type SharedProps<T> = {
1515
SkeletonComponent: ValidSkeletons;
1616
title: string;
1717
titleStyles?: StyleProp<TextStyle>;
18-
subtitle: string | React.ReactNode;
18+
subtitle?: string;
19+
children?: React.ReactNode;
1920
buttons?: Button[];
2021
containerStyles?: StyleProp<ViewStyle>;
2122
headerStyles?: StyleProp<ViewStyle>;

src/components/FeatureList.tsx

+15-40
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import type {ReactNode} from 'react';
23
import {View} from 'react-native';
34
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
45
import useLocalize from '@hooks/useLocalize';
@@ -7,7 +8,6 @@ import variables from '@styles/variables';
78
import type {TranslationPaths} from '@src/languages/types';
89
import type IconAsset from '@src/types/utils/IconAsset';
910
import Button from './Button';
10-
import DotIndicatorMessage from './DotIndicatorMessage';
1111
import type DotLottieAnimation from './LottieAnimations/types';
1212
import MenuItem from './MenuItem';
1313
import Section from './Section';
@@ -33,15 +33,6 @@ type FeatureListProps = {
3333
/** Action to call on cta button press */
3434
onCtaPress?: () => void;
3535

36-
/** Text of the secondary button button */
37-
secondaryButtonText?: string;
38-
39-
/** Accessibility label for the secondary button */
40-
secondaryButtonAccessibilityLabel?: string;
41-
42-
/** Action to call on secondary button press */
43-
onSecondaryButtonPress?: () => void;
44-
4536
/** A list of menuItems representing the feature list. */
4637
menuItems: FeatureListItem[];
4738

@@ -60,30 +51,27 @@ type FeatureListProps = {
6051
/** The style used for the title */
6152
titleStyles?: StyleProp<TextStyle>;
6253

63-
/** The error message to display for the CTA button */
64-
ctaErrorMessage?: string;
65-
6654
/** Padding for content on large screens */
6755
contentPaddingOnLargeScreens?: {padding: number};
56+
57+
/** Custom content to display in the footer */
58+
footer?: ReactNode;
6859
};
6960

7061
function FeatureList({
7162
title,
7263
subtitle = '',
73-
ctaText = '',
74-
ctaAccessibilityLabel = '',
75-
onCtaPress = () => {},
76-
secondaryButtonText = '',
77-
secondaryButtonAccessibilityLabel = '',
78-
onSecondaryButtonPress = () => {},
79-
ctaErrorMessage,
64+
ctaText,
65+
ctaAccessibilityLabel,
66+
onCtaPress,
8067
menuItems,
8168
illustration,
8269
illustrationStyle,
8370
illustrationBackgroundColor,
8471
illustrationContainerStyle,
8572
titleStyles,
8673
contentPaddingOnLargeScreens,
74+
footer,
8775
}: FeatureListProps) {
8876
const styles = useThemeStyles();
8977
const {translate} = useLocalize();
@@ -122,30 +110,17 @@ function FeatureList({
122110
</View>
123111
))}
124112
</View>
125-
{!!secondaryButtonText && (
113+
{!!ctaText && (
126114
<Button
127-
text={secondaryButtonText}
128-
onPress={onSecondaryButtonPress}
129-
accessibilityLabel={secondaryButtonAccessibilityLabel}
130-
style={[styles.w100, styles.mb3]}
115+
text={ctaText}
116+
onPress={onCtaPress}
117+
accessibilityLabel={ctaAccessibilityLabel}
118+
style={styles.w100}
119+
success
131120
large
132121
/>
133122
)}
134-
{!!ctaErrorMessage && (
135-
<DotIndicatorMessage
136-
style={styles.mb1}
137-
messages={{error: ctaErrorMessage}}
138-
type="error"
139-
/>
140-
)}
141-
<Button
142-
text={ctaText}
143-
onPress={onCtaPress}
144-
accessibilityLabel={ctaAccessibilityLabel}
145-
style={styles.w100}
146-
success
147-
large
148-
/>
123+
{!!footer && footer}
149124
</View>
150125
</Section>
151126
);

src/libs/actions/Travel.ts

+3-114
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,10 @@
1-
import {Str} from 'expensify-common';
2-
import type {Dispatch, SetStateAction} from 'react';
3-
import {Linking, NativeModules} from 'react-native';
4-
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
1+
import type {OnyxUpdate} from 'react-native-onyx';
52
import Onyx from 'react-native-onyx';
6-
import type {LocaleContextProps} from '@components/LocaleContextProvider';
73
import * as API from '@libs/API';
84
import type {AcceptSpotnanaTermsParams} from '@libs/API/parameters';
95
import {WRITE_COMMANDS} from '@libs/API/types';
106
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
11-
import Log from '@libs/Log';
12-
import Navigation from '@libs/Navigation/Navigation';
13-
import {getAdminsPrivateEmailDomains, getPolicy} from '@libs/PolicyUtils';
14-
import CONST from '@src/CONST';
157
import ONYXKEYS from '@src/ONYXKEYS';
16-
import ROUTES from '@src/ROUTES';
17-
import type {TravelSettings} from '@src/types/onyx';
18-
import {isEmptyObject} from '@src/types/utils/EmptyObject';
19-
import {buildTravelDotURL, openTravelDotLink} from './Link';
20-
21-
let travelSettings: OnyxEntry<TravelSettings>;
22-
Onyx.connect({
23-
key: ONYXKEYS.NVP_TRAVEL_SETTINGS,
24-
callback: (val) => {
25-
travelSettings = val;
26-
},
27-
});
28-
29-
let activePolicyID: OnyxEntry<string>;
30-
Onyx.connect({
31-
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
32-
callback: (val) => {
33-
activePolicyID = val;
34-
},
35-
});
36-
37-
let primaryLogin: string;
38-
Onyx.connect({
39-
key: ONYXKEYS.ACCOUNT,
40-
callback: (val) => {
41-
primaryLogin = val?.primaryLogin ?? '';
42-
},
43-
});
44-
45-
let isSingleNewDotEntry: boolean | undefined;
46-
Onyx.connect({
47-
key: ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY,
48-
callback: (val) => {
49-
isSingleNewDotEntry = val;
50-
},
51-
});
528

539
/**
5410
* Accept Spotnana terms and conditions to receive a proper token used for authenticating further actions
@@ -98,76 +54,9 @@ function acceptSpotnanaTerms(domain?: string) {
9854
API.write(WRITE_COMMANDS.ACCEPT_SPOTNANA_TERMS, params, {optimisticData, successData, failureData});
9955
}
10056

101-
function handleProvisioningPermissionDeniedError(domain: string) {
102-
Navigation.navigate(ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.getRoute(domain));
103-
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
104-
}
105-
106-
function openTravelDotAfterProvisioning(spotnanaToken: string) {
107-
Navigation.closeRHPFlow();
57+
function cleanupTravelProvisioningSession() {
10858
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
109-
Linking.openURL(buildTravelDotURL(spotnanaToken));
110-
}
111-
112-
function provisionDomain(domain: string) {
113-
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
114-
Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(domain));
115-
}
116-
117-
function bookATrip(
118-
translate: LocaleContextProps['translate'],
119-
setCtaErrorMessage: Dispatch<SetStateAction<string>>,
120-
setRootStatusBarEnabled: (isEnabled: boolean) => void,
121-
ctaErrorMessage = '',
122-
): void {
123-
if (!activePolicyID) {
124-
return;
125-
}
126-
if (Str.isSMSLogin(primaryLogin)) {
127-
setCtaErrorMessage(translate('travel.phoneError'));
128-
return;
129-
}
130-
const policy = getPolicy(activePolicyID);
131-
if (isEmptyObject(policy?.address)) {
132-
Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(activePolicyID, Navigation.getActiveRoute()));
133-
return;
134-
}
135-
136-
const isPolicyProvisioned = policy?.travelSettings?.spotnanaCompanyID ?? policy?.travelSettings?.associatedTravelDomainAccountID;
137-
if (policy?.travelSettings?.hasAcceptedTerms ?? (travelSettings?.hasAcceptedTerms && isPolicyProvisioned)) {
138-
openTravelDotLink(activePolicyID)
139-
?.then(() => {
140-
if (!NativeModules.HybridAppModule || !isSingleNewDotEntry) {
141-
return;
142-
}
143-
144-
Log.info('[HybridApp] Returning to OldDot after opening TravelDot');
145-
NativeModules.HybridAppModule.closeReactNativeApp(false, false);
146-
setRootStatusBarEnabled(false);
147-
})
148-
?.catch(() => {
149-
setCtaErrorMessage(translate('travel.errorMessage'));
150-
});
151-
if (ctaErrorMessage) {
152-
setCtaErrorMessage('');
153-
}
154-
} else if (isPolicyProvisioned) {
155-
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
156-
Navigation.navigate(ROUTES.TRAVEL_TCS.getRoute(CONST.TRAVEL.DEFAULT_DOMAIN));
157-
} else {
158-
const adminDomains = getAdminsPrivateEmailDomains(policy);
159-
let routeToNavigateTo;
160-
if (adminDomains.length === 0) {
161-
routeToNavigateTo = ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR;
162-
} else if (adminDomains.length === 1) {
163-
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
164-
routeToNavigateTo = ROUTES.TRAVEL_TCS.getRoute(adminDomains.at(0) ?? CONST.TRAVEL.DEFAULT_DOMAIN);
165-
} else {
166-
routeToNavigateTo = ROUTES.TRAVEL_DOMAIN_SELECTOR;
167-
}
168-
Navigation.navigate(routeToNavigateTo);
169-
}
17059
}
17160

17261
// eslint-disable-next-line import/prefer-default-export
173-
export {acceptSpotnanaTerms, handleProvisioningPermissionDeniedError, openTravelDotAfterProvisioning, provisionDomain, bookATrip};
62+
export {acceptSpotnanaTerms, cleanupTravelProvisioningSession};

0 commit comments

Comments
 (0)