Skip to content

Commit

Permalink
Merge pull request #53753 from shubham1206agra/upgrade-travel
Browse files Browse the repository at this point in the history
Implement upgrade to collect when booking travel
  • Loading branch information
cristipaval authored Feb 6, 2025
2 parents f02e47f + 52b8f8c commit c550073
Show file tree
Hide file tree
Showing 13 changed files with 366 additions and 191 deletions.
8 changes: 8 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6426,6 +6426,14 @@ const CONST = {
description: 'workspace.upgrade.perDiem.description' as const,
icon: 'PerDiem',
},
travel: {
id: 'travel' as const,
alias: 'travel',
name: 'Travel',
title: 'workspace.upgrade.travel.title' as const,
description: 'workspace.upgrade.travel.description' as const,
icon: 'Luggage',
},
};
},
REPORT_FIELD_TYPES: {
Expand Down
1 change: 1 addition & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,7 @@ const ROUTES = {
route: 'travel/terms/:domain/accept',
getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`travel/terms/${domain}/accept`, backTo),
},
TRAVEL_UPGRADE: 'travel/upgrade',
TRACK_TRAINING_MODAL: 'track-training',
TRAVEL_TRIP_SUMMARY: {
route: 'r/:reportID/trip/:transactionID',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const SCREENS = {
TCS: 'Travel_TCS',
TRIP_SUMMARY: 'Travel_TripSummary',
TRIP_DETAILS: 'Travel_TripDetails',
UPGRADE: 'Travel_Upgrade',
DOMAIN_SELECTOR: 'Travel_DomainSelector',
DOMAIN_PERMISSION_INFO: 'Travel_DomainPermissionInfo',
PUBLIC_DOMAIN_ERROR: 'Travel_PublicDomainError',
Expand Down
7 changes: 6 additions & 1 deletion src/components/BookTravelButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {openTravelDotLink} from '@libs/actions/Link';
import {cleanupTravelProvisioningSession} from '@libs/actions/Travel';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import {getAdminsPrivateEmailDomains} from '@libs/PolicyUtils';
import {getAdminsPrivateEmailDomains, isPaidGroupPolicy} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand Down Expand Up @@ -52,6 +52,11 @@ function BookTravelButton({text}: BookTravelButtonProps) {
return;
}

if (!isPaidGroupPolicy(policy)) {
Navigation.navigate(ROUTES.TRAVEL_UPGRADE);
return;
}

// Spotnana requires an address anytime an entity is created for a policy
if (isEmptyObject(policy?.address)) {
Navigation.navigate(ROUTES.WORKSPACE_PROFILE_ADDRESS.getRoute(policy?.id, Navigation.getActiveRoute()));
Expand Down
208 changes: 208 additions & 0 deletions src/components/WorkspaceConfirmationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import React, {useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {generateDefaultWorkspaceName, generatePolicyID} from '@libs/actions/Policy/Policy';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import {addErrorMessage} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {isRequiredFulfilled} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/WorkspaceConfirmationForm';
import Avatar from './Avatar';
import AvatarWithImagePicker from './AvatarWithImagePicker';
import CurrencyPicker from './CurrencyPicker';
import FormProvider from './Form/FormProvider';
import InputWrapper from './Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from './Form/types';
import HeaderWithBackButton from './HeaderWithBackButton';
import * as Expensicons from './Icon/Expensicons';
import ScrollView from './ScrollView';
import Text from './Text';
import TextInput from './TextInput';

function getFirstAlphaNumericCharacter(str = '') {
return str
.normalize('NFD')
.replace(/[^0-9a-z]/gi, '')
.toUpperCase()[0];
}

type WorkspaceConfirmationSubmitFunctionParams = {
name: string;
currency: string;
avatarFile: File | CustomRNImageManipulatorResult | undefined;
policyID: string;
};

type WorkspaceConfirmationFormProps = {
/** The email of the workspace owner
* @summary Approved Accountants and Guides can enter a flow where they make a workspace for other users,
* and those are passed as a search parameter when using transition links
*/
policyOwnerEmail?: string;

/** Submit function */
onSubmit: (params: WorkspaceConfirmationSubmitFunctionParams) => void;

/** go back function */
onBackButtonPress?: () => void;
};

function WorkspaceConfirmationForm({onSubmit, policyOwnerEmail = '', onBackButtonPress = () => Navigation.goBack()}: WorkspaceConfirmationFormProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();

const validate = useCallback(
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM>) => {
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM> = {};
const name = values.name.trim();

if (!isRequiredFulfilled(name)) {
errors.name = translate('workspace.editor.nameIsRequiredError');
} else if ([...name].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.
addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', {length: [...name].length, limit: CONST.TITLE_CHARACTER_LIMIT}));
}

if (!isRequiredFulfilled(values[INPUT_IDS.CURRENCY])) {
errors[INPUT_IDS.CURRENCY] = translate('common.error.fieldRequired');
}

return errors;
},
[translate],
);

const policyID = useMemo(() => generatePolicyID(), []);
const [session] = useOnyx(ONYXKEYS.SESSION);

const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);

const defaultWorkspaceName = generateDefaultWorkspaceName(policyOwnerEmail);
const [workspaceNameFirstCharacter, setWorkspaceNameFirstCharacter] = useState(defaultWorkspaceName ?? '');

const userCurrency = allPersonalDetails?.[session?.accountID ?? CONST.DEFAULT_NUMBER_ID]?.localCurrencyCode ?? CONST.CURRENCY.USD;

const [workspaceAvatar, setWorkspaceAvatar] = useState<{avatarUri: string | null; avatarFileName?: string | null; avatarFileType?: string | null}>({
avatarUri: null,
avatarFileName: null,
avatarFileType: null,
});
const [avatarFile, setAvatarFile] = useState<File | CustomRNImageManipulatorResult | undefined>();

const stashedLocalAvatarImage = workspaceAvatar?.avatarUri ?? undefined;

const DefaultAvatar = useCallback(
() => (
<Avatar
containerStyles={styles.avatarXLarge}
imageStyles={[styles.avatarXLarge, styles.alignSelfCenter]}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string
source={workspaceAvatar?.avatarUri || getDefaultWorkspaceAvatar(workspaceNameFirstCharacter)}
fallbackIcon={Expensicons.FallbackWorkspaceAvatar}
size={CONST.AVATAR_SIZE.XLARGE}
name={workspaceNameFirstCharacter}
avatarID={policyID}
type={CONST.ICON_TYPE_WORKSPACE}
/>
),
[workspaceAvatar?.avatarUri, workspaceNameFirstCharacter, styles.alignSelfCenter, styles.avatarXLarge, policyID],
);

return (
<>
<HeaderWithBackButton
title={translate('workspace.new.confirmWorkspace')}
onBackButtonPress={onBackButtonPress}
/>
<ScrollView
contentContainerStyle={styles.flexGrow1}
keyboardShouldPersistTaps="always"
>
<View style={[styles.ph5, styles.pv3]}>
<Text style={[styles.mb3, styles.webViewStyles.baseFontStyle, styles.textSupporting]}>{translate('workspace.emptyWorkspace.subtitle')}</Text>
</View>
<AvatarWithImagePicker
isUsingDefaultAvatar={!stashedLocalAvatarImage}
// eslint-disable-next-line react-compiler/react-compiler
avatarID={policyID}
source={stashedLocalAvatarImage}
onImageSelected={(image) => {
setAvatarFile(image);
setWorkspaceAvatar({avatarUri: image.uri ?? '', avatarFileName: image.name ?? '', avatarFileType: image.type});
}}
onImageRemoved={() => {
setAvatarFile(undefined);
setWorkspaceAvatar({avatarUri: null, avatarFileName: null, avatarFileType: null});
}}
size={CONST.AVATAR_SIZE.XLARGE}
avatarStyle={[styles.avatarXLarge, styles.alignSelfCenter]}
shouldDisableViewPhoto
editIcon={Expensicons.Camera}
editIconStyle={styles.smallEditIconAccount}
type={CONST.ICON_TYPE_WORKSPACE}
style={[styles.w100, styles.alignItemsCenter, styles.mv4, styles.mb6, styles.alignSelfCenter, styles.ph5]}
DefaultAvatar={DefaultAvatar}
editorMaskImage={Expensicons.ImageCropSquareMask}
/>
<FormProvider
formID={ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM}
submitButtonText={translate('common.confirm')}
style={[styles.flexGrow1, styles.ph5]}
scrollContextEnabled
validate={validate}
onSubmit={(val) =>
onSubmit({
name: val[INPUT_IDS.NAME],
currency: val[INPUT_IDS.CURRENCY],
avatarFile,
policyID,
})
}
enabledWhenOffline
>
<View style={styles.mb4}>
<InputWrapper
InputComponent={TextInput}
role={CONST.ROLE.PRESENTATION}
inputID={INPUT_IDS.NAME}
label={translate('workspace.common.workspaceName')}
accessibilityLabel={translate('workspace.common.workspaceName')}
spellCheck={false}
defaultValue={defaultWorkspaceName}
onChangeText={(str) => {
if (getFirstAlphaNumericCharacter(str) === getFirstAlphaNumericCharacter(workspaceNameFirstCharacter)) {
return;
}
setWorkspaceNameFirstCharacter(str);
}}
ref={inputCallbackRef}
/>

<View style={[styles.mhn5, styles.mt4]}>
<InputWrapper
InputComponent={CurrencyPicker}
inputID={INPUT_IDS.CURRENCY}
label={translate('workspace.editor.currencyInputLabel')}
defaultValue={userCurrency}
/>
</View>
</View>
</FormProvider>
</ScrollView>
</>
);
}

WorkspaceConfirmationForm.displayName = 'WorkspaceConfirmationForm';

export default WorkspaceConfirmationForm;

export type {WorkspaceConfirmationSubmitFunctionParams};
8 changes: 7 additions & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3944,7 +3944,7 @@ const translations = {
},
emptyWorkspace: {
title: 'Create a workspace',
subtitle: 'Create a workspace to track receipts, reimburse expenses, send invoices, and more — all at the speed of chat.',
subtitle: 'Create a workspace to track receipts, reimburse expenses, manage travel, send invoices, and more — all at the speed of chat.',
createAWorkspaceCTA: 'Get Started',
features: {
trackAndCollect: 'Track and collect receipts',
Expand Down Expand Up @@ -4520,6 +4520,11 @@ const translations = {
'Per diem is a great way to keep your daily costs compliant and predictable whenever your employees travel. Enjoy features like custom rates, default categories, and more granular details like destinations and subrates.',
onlyAvailableOnPlan: 'Per diem are only available on the Control plan, starting at ',
},
travel: {
title: 'Travel',
description: 'Expensify Travel is a new corporate travel booking and management platform that allows members to book accommodations, flights, transportation, and more.',
onlyAvailableOnPlan: 'Travel is available on the Collect plan, starting at ',
},
pricing: {
perActiveMember: 'per active member per month.',
},
Expand All @@ -4533,6 +4538,7 @@ const translations = {
headline: `You've upgraded your workspace!`,
successMessage: ({policyName}: ReportPolicyNameParams) => `You've successfully upgraded ${policyName} to the Control plan!`,
categorizeMessage: `You've successfully upgraded to a workspace on the Collect plan. Now you can categorize your expenses!`,
travelMessage: `You've successfully upgraded to a workspace on the Collect plan. Now you can start booking and managing travel!`,
viewSubscription: 'View your subscription',
moreDetails: 'for more details.',
gotIt: 'Got it, thanks',
Expand Down
9 changes: 8 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3988,7 +3988,7 @@ const translations = {
},
emptyWorkspace: {
title: 'Crea un espacio de trabajo',
subtitle: 'Crea un espacio de trabajo para organizar recibos, reembolsar gastos, enviar facturas y mucho más, todo a la velocidad del chat.',
subtitle: 'Crea un espacio de trabajo para organizar recibos, reembolsar gastos, gestionar viajes, enviar facturas y mucho más, todo a la velocidad del chat.',
createAWorkspaceCTA: 'Comenzar',
features: {
trackAndCollect: 'Organiza recibos',
Expand Down Expand Up @@ -4586,6 +4586,12 @@ const translations = {
'Las dietas per diem (ej.: $100 por día para comidas) son una excelente forma de mantener los gastos diarios predecibles y ajustados a las políticas de la empresa, especialmente si tus empleados viajan por negocios. Disfruta de funciones como tasas personalizadas, categorías por defecto y detalles más específicos como destinos y subtasas.',
onlyAvailableOnPlan: 'Las dietas per diem solo están disponibles en el plan Control, a partir de ',
},
travel: {
title: 'Viajes',
description:
'Expensify Travel es una nueva plataforma corporativa de reserva y gestión de viajes que permite a los miembros reservar alojamientos, vuelos, transporte y mucho más.',
onlyAvailableOnPlan: 'Los viajes están disponibles en el plan Recopilar, a partir de ',
},
note: {
upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o',
learnMore: 'más información',
Expand All @@ -4598,6 +4604,7 @@ const translations = {
completed: {
headline: 'Has mejorado tu espacio de trabajo.',
categorizeMessage: `Has actualizado con éxito a un espacio de trabajo en el plan Recopilar. ¡Ahora puedes categorizar tus gastos!`,
travelMessage: 'Has mejorado con éxito a un espacio de trabajo en el plan Recopilar. ¡Ahora puedes comenzar a reservar y gestionar viajes!',
successMessage: ({policyName}: ReportPolicyNameParams) => `Has actualizado con éxito ${policyName} al plan Controlar.`,
viewSubscription: 'Ver su suscripción',
moreDetails: 'para obtener más información.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator<MoneyRequestNa
const TravelModalStackNavigator = createModalStackNavigator<TravelNavigatorParamList>({
[SCREENS.TRAVEL.MY_TRIPS]: () => require<ReactComponentModule>('../../../../pages/Travel/MyTripsPage').default,
[SCREENS.TRAVEL.TCS]: () => require<ReactComponentModule>('../../../../pages/Travel/TravelTerms').default,
[SCREENS.TRAVEL.UPGRADE]: () => require<ReactComponentModule>('../../../../pages/Travel/TravelUpgrade').default,
[SCREENS.TRAVEL.TRIP_SUMMARY]: () => require<ReactComponentModule>('../../../../pages/Travel/TripSummaryPage').default,
[SCREENS.TRAVEL.TRIP_DETAILS]: () => require<ReactComponentModule>('../../../../pages/Travel/TripDetailsPage').default,
[SCREENS.TRAVEL.DOMAIN_SELECTOR]: () => require<ReactComponentModule>('../../../../pages/Travel/DomainSelectorPage').default,
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,7 @@ const config: LinkingOptions<RootStackParamList>['config'] = {
[SCREENS.RIGHT_MODAL.TRAVEL]: {
screens: {
[SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS,
[SCREENS.TRAVEL.UPGRADE]: ROUTES.TRAVEL_UPGRADE,
[SCREENS.TRAVEL.TCS]: ROUTES.TRAVEL_TCS.route,
[SCREENS.TRAVEL.TRIP_SUMMARY]: ROUTES.TRAVEL_TRIP_SUMMARY.route,
[SCREENS.TRAVEL.TRIP_DETAILS]: {
Expand Down
1 change: 0 additions & 1 deletion src/libs/actions/Travel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,4 @@ function cleanupTravelProvisioningSession() {
Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null);
}

// eslint-disable-next-line import/prefer-default-export
export {acceptSpotnanaTerms, cleanupTravelProvisioningSession};
Loading

0 comments on commit c550073

Please sign in to comment.