diff --git a/src/CONST.ts b/src/CONST.ts index 6eb122802a38..9311816c38a2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3319,6 +3319,11 @@ const CONST = { CONCIERGE_TRAVEL_URL: 'https://community.expensify.com/discussion/7066/introducing-concierge-travel', BOOK_TRAVEL_DEMO_URL: 'https://calendly.com/d/ck2z-xsh-q97/expensify-travel-demo-travel-page', + TRAVEL_DOT_URL: 'https://travel.expensify.com', + STAGING_TRAVEL_DOT_URL: 'https://staging.travel.expensify.com', + TRIP_ID_PATH: (tripID: string) => `trips/${tripID}`, + SPOTNANA_TMC_ID: '8e8e7258-1cf3-48c0-9cd1-fe78a6e31eed', + STAGING_SPOTNANA_TMC_ID: '7a290c6e-5328-4107-aff6-e48765845b81', SCREEN_READER_STATES: { ALL: 'all', ACTIVE: 'active', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 96345ac117ff..58d56b005f0b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,8 +1,9 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxProvider'; @@ -34,6 +35,7 @@ import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; +import * as Link from '@userActions/Link'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -99,6 +101,8 @@ function MoneyRequestView({ const session = useSession(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const parentReportAction = parentReportActions?.[report.parentReportActionID ?? '-1'] ?? null; const isTrackExpense = ReportUtils.isTrackExpenseReport(report); const {canUseViolations, canUseP2PDistanceRequests} = usePermissions(isTrackExpense ? CONST.IOU.TYPE.TRACK : undefined); @@ -169,6 +173,8 @@ function MoneyRequestView({ const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true)); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); + const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); + const shouldShowViewTripDetails = TransactionUtils.hasReservationList(transaction) && !!tripID; const {getViolationsForField} = useViolations(transactionViolations ?? []); const hasViolations = useCallback( @@ -545,6 +551,15 @@ function MoneyRequestView({ /> )} + {shouldShowViewTripDetails && ( + Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))} + /> + )} {shouldShowBillable && ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 37635746fee9..bf3803c7606d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1927,6 +1927,7 @@ export default { hotel: 'Hotel', car: 'Car', viewTrip: 'View trip', + viewTripDetails: 'View trip details', trip: 'Trip', tripSummary: 'Trip summary', departs: 'Departs', diff --git a/src/languages/es.ts b/src/languages/es.ts index 323f2ddb5336..4c900e23acc5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1952,6 +1952,7 @@ export default { hotel: 'Hotel', car: 'Auto', viewTrip: 'Ver viaje', + viewTripDetails: 'Ver detalles del viaje', trip: 'Viaje', tripSummary: 'Resumen del viaje', departs: 'Sale', diff --git a/src/libs/API/parameters/GenerateSpotnanaTokenParams.ts b/src/libs/API/parameters/GenerateSpotnanaTokenParams.ts new file mode 100644 index 000000000000..bc67e9197502 --- /dev/null +++ b/src/libs/API/parameters/GenerateSpotnanaTokenParams.ts @@ -0,0 +1,5 @@ +type GenerateSpotnanaTokenParams = { + policyID: string; +}; + +export default GenerateSpotnanaTokenParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 470adb90001d..306f2c599a4b 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -227,4 +227,5 @@ export type {default as PayInvoiceParams} from './PayInvoiceParams'; export type {default as MarkAsCashParams} from './MarkAsCashParams'; export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams'; export type {default as SignUpUserParams} from './SignUpUserParams'; +export type {default as GenerateSpotnanaTokenParams} from './GenerateSpotnanaTokenParams'; export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscriptionSizeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f06152794f99..68aca761c5b6 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -554,6 +554,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { GET_MISSING_ONYX_MESSAGES: 'GetMissingOnyxMessages', JOIN_POLICY_VIA_INVITE_LINK: 'JoinWorkspaceViaInviteLink', RECONNECT_APP: 'ReconnectApp', + GENERATE_SPOTNANA_TOKEN: 'GenerateSpotnanaToken', } as const; type SideEffectRequestCommand = ValueOf; @@ -566,6 +567,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES]: Parameters.GetMissingOnyxMessagesParams; [SIDE_EFFECT_REQUEST_COMMANDS.JOIN_POLICY_VIA_INVITE_LINK]: Parameters.JoinPolicyInviteLinkParams; [SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP]: Parameters.ReconnectAppParams; + [SIDE_EFFECT_REQUEST_COMMANDS.GENERATE_SPOTNANA_TOKEN]: Parameters.GenerateSpotnanaTokenParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 204e78aa5458..1f5a391d3b13 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -17,6 +17,20 @@ const OLDDOT_ENVIRONMENT_URLS = { [CONST.ENVIRONMENT.ADHOC]: CONST.STAGING_EXPENSIFY_URL, }; +const TRAVELDOT_ENVIRONMENT_URLS: Record = { + [CONST.ENVIRONMENT.DEV]: CONST.STAGING_TRAVEL_DOT_URL, + [CONST.ENVIRONMENT.STAGING]: CONST.STAGING_TRAVEL_DOT_URL, + [CONST.ENVIRONMENT.PRODUCTION]: CONST.TRAVEL_DOT_URL, + [CONST.ENVIRONMENT.ADHOC]: CONST.STAGING_TRAVEL_DOT_URL, +}; + +const SPOTNANA_ENVIRONMENT_TMC_ID: Record = { + [CONST.ENVIRONMENT.DEV]: CONST.STAGING_SPOTNANA_TMC_ID, + [CONST.ENVIRONMENT.STAGING]: CONST.STAGING_SPOTNANA_TMC_ID, + [CONST.ENVIRONMENT.PRODUCTION]: CONST.SPOTNANA_TMC_ID, + [CONST.ENVIRONMENT.ADHOC]: CONST.STAGING_SPOTNANA_TMC_ID, +}; + /** * Are we running the app in development? */ @@ -54,4 +68,12 @@ function getOldDotEnvironmentURL(): Promise { return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL}; +function getTravelDotEnvironmentURL(): Promise { + return getEnvironment().then((environment) => TRAVELDOT_ENVIRONMENT_URLS[environment]); +} + +function getSpotnanaEnvironmentTMCID(): Promise { + return getEnvironment().then((environment) => SPOTNANA_ENVIRONMENT_TMC_ID[environment]); +} + +export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 40d7155397f1..a02b24c20e35 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6779,6 +6779,10 @@ function getTripTransactions(tripRoomReportID: string | undefined): Transaction[ return tripTransactionReportIDs.flatMap((reportID) => TransactionUtils.getAllReportTransactions(reportID)); } +function getTripIDFromTransactionParentReport(transactionParentReport: OnyxEntry | undefined | null): string | undefined { + return getReport(transactionParentReport?.parentReportID)?.tripData?.tripID; +} + /** * Checks if report contains actions with errors */ @@ -7224,6 +7228,7 @@ export { updateReportPreview, temporary_getMoneyRequestOptions, getTripTransactions, + getTripIDFromTransactionParentReport, buildOptimisticInvoiceReport, getInvoiceChatByParticipants, shouldShowMerchantColumn, diff --git a/src/libs/Url.ts b/src/libs/Url.ts index 69894147a242..970e6b3ed195 100644 --- a/src/libs/Url.ts +++ b/src/libs/Url.ts @@ -11,6 +11,13 @@ function addTrailingForwardSlash(url: string): string { return url; } +function addLeadingForwardSlash(url: string): string { + if (!url.startsWith('/')) { + return `/${url}`; + } + return url; +} + /** * Get path from URL string */ @@ -63,4 +70,4 @@ function hasURL(text: string) { return urlPattern.test(text); } -export {addTrailingForwardSlash, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL}; +export {addTrailingForwardSlash, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL, addLeadingForwardSlash}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 7e37cf159bad..f97c4e17a9a5 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -1,5 +1,7 @@ import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import * as API from '@libs/API'; +import type {GenerateSpotnanaTokenParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; import * as Environment from '@libs/Environment/Environment'; @@ -64,6 +66,40 @@ function openOldDotLink(url: string) { ); } +function buildTravelDotURL(spotnanaToken?: string, postLoginPath?: string): Promise { + return Promise.all([Environment.getTravelDotEnvironmentURL(), Environment.getSpotnanaEnvironmentTMCID()]).then(([environmentURL, tmcID]) => { + const authCode = spotnanaToken ? `authCode=${spotnanaToken}` : ''; + const redirectURL = postLoginPath ? `redirectUrl=${Url.addLeadingForwardSlash(postLoginPath)}` : ''; + const tmcIDParam = `tmcId=${tmcID}`; + + const paramsArray = [authCode, tmcIDParam, redirectURL]; + const params = paramsArray.filter(Boolean).join('&'); + const travelDotDomain = Url.addTrailingForwardSlash(environmentURL); + return `${travelDotDomain}auth/code?${params}`; + }); +} + +/** + * @param postLoginPath When provided, we will redirect the user to this path post login on travelDot. eg: 'trips/:tripID' + */ +function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string) { + if (policyID === null || policyID === undefined) { + return; + } + + const parameters: GenerateSpotnanaTokenParams = { + policyID, + }; + + asyncOpenURL( + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.GENERATE_SPOTNANA_TOKEN, parameters, {}) + .then((response) => (response?.spotnanaToken ? buildTravelDotURL(response.spotnanaToken, postLoginPath) : buildTravelDotURL())) + .catch(() => buildTravelDotURL()), + (travelDotURL) => travelDotURL, + ); +} + function getInternalNewExpensifyPath(href: string) { const attrPath = Url.getPathFromURL(href); return (Url.hasSameExpensifyOrigin(href, CONST.NEW_EXPENSIFY_URL) || Url.hasSameExpensifyOrigin(href, CONST.STAGING_NEW_EXPENSIFY_URL) || href.startsWith(CONST.DEV_NEW_EXPENSIFY_URL)) && @@ -121,4 +157,4 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } -export {buildOldDotURL, openOldDotLink, openExternalLink, openLink, getInternalNewExpensifyPath, getInternalExpensifyPath}; +export {buildOldDotURL, openOldDotLink, openExternalLink, openLink, getInternalNewExpensifyPath, getInternalExpensifyPath, openTravelDotLink}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 77409c036696..9b0b35a6da7c 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -274,6 +274,18 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Collection of report permissions granted to the current user */ permissions?: Array>; + + /** The trip data for a trip room */ + tripData?: { + /** The start date of a trip */ + startDate: string; + + /** The end date of a trip */ + endDate: string; + + /** The trip ID in spotnana */ + tripID: string; + }; }, PolicyReportField['fieldID'] >; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index aa060223637b..c26280652254 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -56,6 +56,9 @@ type Response = { /** Short lived auth token generated by API */ shortLivedAuthToken?: string; + /** Short lived token generated by spotnana for authenticating travelDot */ + spotnanaToken?: string; + /** User authorization token to authorize Pusher connections */ auth?: string;