Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6421,6 +6421,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 @@ -3942,7 +3942,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 @@ -4518,6 +4518,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 @@ -4531,6 +4536,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 @@ -3986,7 +3986,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 @@ -4584,6 +4584,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 @@ -4596,6 +4602,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
Loading