Skip to content

Commit

Permalink
feat: support multiple PreassignedFareProducts per offer search (#4679)
Browse files Browse the repository at this point in the history
  • Loading branch information
rosvik authored Aug 22, 2024
1 parent c7527a1 commit 234e0ae
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 39 deletions.
24 changes: 24 additions & 0 deletions src/configuration/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {PreassignedFareProduct} from '..';
import {removeProductAliasDuplicates} from '../utils';

describe('removeProductAliasDuplicates', () => {
it('removes duplicates', () => {
const products = [
{productAliasId: 'id-1'},
{productAliasId: 'id-1'},
{productAliasId: 'id-2'},
] as PreassignedFareProduct[];
const dedupedProducts = products.filter(removeProductAliasDuplicates);
expect(dedupedProducts.length).toEqual(2);
});

it('does not remove undefined', () => {
const products = [
{productAliasId: 'id-1'},
{productAliasId: undefined},
{productAliasId: undefined},
] as PreassignedFareProduct[];
const dedupedProducts = products.filter(removeProductAliasDuplicates);
expect(dedupedProducts.length).toEqual(3);
});
});
1 change: 1 addition & 0 deletions src/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ export {
isProductSellableInApp,
findReferenceDataById,
getReferenceDataName,
removeProductAliasDuplicates,
} from './utils';
export {useFirestoreConfiguration} from './FirestoreConfigurationContext';
1 change: 1 addition & 0 deletions src/configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PreassignedFareProduct = {
productDescription?: LanguageAndTextType[];
warningMessage?: LanguageAndTextType[];
type: string;
productAliasId?: string;
productAlias?: LanguageAndTextType[];
durationDays?: number;
distributionChannel: DistributionChannel[];
Expand Down
9 changes: 9 additions & 0 deletions src/configuration/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,12 @@ export const isProductSellableInApp = (

return product.distributionChannel.some((channel) => channel === 'app');
};

export const removeProductAliasDuplicates = (
val: PreassignedFareProduct,
i: number,
arr: PreassignedFareProduct[],
): boolean => {
if (val.productAliasId === undefined) return true;
return arr.map((p) => p.productAliasId).indexOf(val.productAliasId) === i;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import {formatToLongDateTime, secondsToDuration} from '@atb/utils/date';
import {formatDecimalNumber} from '@atb/utils/numbers';
import {addMinutes, parseISO} from 'date-fns';
import React, {useEffect, useState} from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {
ActivityIndicator,
ScrollView,
Expand Down Expand Up @@ -122,6 +122,11 @@ export const Root_PurchaseConfirmationScreen: React.FC<Props> = ({

const isOnBehalfOf = !!recipient;

const preassignedFareProductAlternatives = useMemo(
() => [preassignedFareProduct],
[preassignedFareProduct],
);

const {
offerSearchTime,
isSearchingOffer,
Expand All @@ -132,7 +137,7 @@ export const Root_PurchaseConfirmationScreen: React.FC<Props> = ({
userProfilesWithCountAndOffer,
} = useOfferState(
offerEndpoint,
preassignedFareProduct,
preassignedFareProductAlternatives,
fromPlace,
toPlace,
userProfilesWithCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@ export const Root_PurchaseOverviewScreen: React.FC<Props> = ({
? 'isFree' in params.toPlace && !!params.toPlace.isFree
: false;

const {preassignedFareProduct, selectableTravellers, fromPlace, toPlace} =
useOfferDefaults(
params.preassignedFareProduct,
params.fareProductTypeConfig.type,
params.userProfilesWithCount,
params.fromPlace,
params.toPlace,
);
const {
preassignedFareProductAlternatives,
selectableTravellers,
fromPlace,
toPlace,
} = useOfferDefaults(
params.preassignedFareProduct,
params.fareProductTypeConfig.type,
params.userProfilesWithCount,
params.fromPlace,
params.toPlace,
);

const onSelectPreassignedFareProduct = (fp: PreassignedFareProduct) => {
navigation.setParams({
Expand Down Expand Up @@ -114,14 +118,19 @@ export const Root_PurchaseOverviewScreen: React.FC<Props> = ({
userProfilesWithCountAndOffer,
} = useOfferState(
offerEndpoint,
preassignedFareProduct,
preassignedFareProductAlternatives,
fromPlace,
toPlace,
travellerSelection,
isOnBehalfOfToggle,
travelDate,
);

const preassignedFareProduct =
preassignedFareProductAlternatives.find(
(p) => p.id === userProfilesWithCountAndOffer[0]?.offer.fare_product,
) ?? preassignedFareProductAlternatives[0];

const rootPurchaseConfirmationScreenParams: Root_PurchaseConfirmationScreenParams =
{
fareProductTypeConfig: params.fareProductTypeConfig,
Expand Down Expand Up @@ -181,7 +190,9 @@ export const Root_PurchaseOverviewScreen: React.FC<Props> = ({

const focusRefs = useFocusRefs(params.onFocusElement);

const userTypeStrings = userProfilesWithCountAndOffer.filter((u) => u.count > 0).map((u) => u.userTypeString);
const userTypeStrings = userProfilesWithCountAndOffer
.filter((u) => u.count > 0)
.map((u) => u.userTypeString);

return (
<FullScreenView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PreassignedFareProduct,
isProductSellableInApp,
FareProductTypeConfig,
removeProductAliasDuplicates,
} from '@atb/configuration';
import {useTextForLanguage} from '@atb/translations/utils';
import {ProductAliasChip} from '@atb/stacks-hierarchy/Root_PurchaseOverviewScreen/components/ProductAliasChip';
Expand Down Expand Up @@ -40,7 +41,8 @@ export function ProductSelectionByAlias({

const selectableProducts = preassignedFareProducts
.filter((product) => isProductSellableInApp(product, customerProfile))
.filter((product) => product.type === selectedProduct.type);
.filter((product) => product.type === selectedProduct.type)
.filter(removeProductAliasDuplicates);

const title = useTextForLanguage(
fareProductTypeConfig.configuration.productSelectionTitle,
Expand All @@ -66,7 +68,11 @@ export function ProductSelectionByAlias({
<ProductAliasChip
color={color}
text={text}
selected={selectedProduct.id === fp.id}
selected={
selectedProduct.productAliasId
? selectedProduct.productAliasId === fp.productAliasId
: selectedProduct.id === fp.id
}
onPress={() => setSelectedProduct(fp)}
key={i}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useFirestoreConfiguration,
getReferenceDataName,
isProductSellableInApp,
removeProductAliasDuplicates,
} from '@atb/configuration';
import {FareProductTypeConfig} from '@atb/configuration';
import {useTextForLanguage} from '@atb/translations/utils';
Expand Down Expand Up @@ -43,7 +44,9 @@ export function ProductSelectionByProducts({

const selectableProducts = preassignedFareProducts
.filter((product) => isProductSellableInApp(product, customerProfile))
.filter((product) => product.type === selectedProduct.type);
.filter((product) => product.type === selectedProduct.type)
.filter(removeProductAliasDuplicates);

const [selected, setProduct] = useState(selectedProduct);

const alias = (fareProduct: PreassignedFareProduct) =>
Expand Down Expand Up @@ -76,7 +79,7 @@ export function ProductSelectionByProducts({
<ProductDescriptionToggle title={title} />
<RadioGroupSection<PreassignedFareProduct>
items={selectableProducts}
keyExtractor={(u) => u.id}
keyExtractor={(u) => u.productAliasId ?? u.id}
itemToText={(fp) => productDisplayName(fp)}
hideSubtext={hideProductDescriptions}
itemToSubtext={(fp) => subText(fp)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {UserProfileWithCount} from '@atb/fare-contracts';
import {TariffZoneWithMetadata} from '@atb/tariff-zones-selector';
import {useTicketingState} from '@atb/ticketing';
import {StopPlaceFragment} from '@atb/api/types/generated/fragments/stop-places';
import {useDefaultTariffZone, useFilterTariffZone} from '@atb/stacks-hierarchy/utils';
import {
useDefaultTariffZone,
useFilterTariffZone,
} from '@atb/stacks-hierarchy/utils';
import {useMemo} from 'react';
import {useDefaultPreassignedFareProduct} from '@atb/fare-contracts/utils';
import {useGetFareProductsQuery} from '@atb/ticketing/use-get-fare-products-query';
Expand All @@ -27,10 +30,11 @@ export function useOfferDefaults(
toPlace?: TariffZoneWithMetadata | StopPlaceFragment,
) {
const {data: fareProducts} = useGetFareProductsQuery();
const {tariffZones, userProfiles} = useFirestoreConfiguration();
const {tariffZones, userProfiles, preassignedFareProducts} =
useFirestoreConfiguration();
const {customerProfile} = useTicketingState();

// Get default PreassignedFareProduct
// Get default PreassignedFareProduct alternatives
const productType = preassignedFareProduct?.type ?? selectableProductType;
const selectableProducts = fareProducts
.filter((product) => isProductSellableInApp(product, customerProfile))
Expand All @@ -39,10 +43,22 @@ export function useOfferDefaults(
useDefaultPreassignedFareProduct(selectableProducts);
const defaultPreassignedFareProduct =
preassignedFareProduct ?? defaultFareProduct;
const defaultPreassignedFareProductAlternatives = useMemo(() => {
const productAliasId = defaultPreassignedFareProduct.productAliasId;
return productAliasId
? preassignedFareProducts.filter(
(fp) => fp.productAliasId === productAliasId,
)
: [defaultPreassignedFareProduct];
}, [defaultPreassignedFareProduct, preassignedFareProducts]);

// Check for whitelisted zones
const allowedTariffZoneRefs = defaultPreassignedFareProduct.limitations.tariffZoneRefs ?? [];
const usableTariffZones = useFilterTariffZone(tariffZones, allowedTariffZoneRefs);
const allowedTariffZoneRefs =
defaultPreassignedFareProduct.limitations.tariffZoneRefs ?? [];
const usableTariffZones = useFilterTariffZone(
tariffZones,
allowedTariffZoneRefs,
);

// Get default TariffZones
const defaultTariffZone = useDefaultTariffZone(usableTariffZones);
Expand Down Expand Up @@ -77,7 +93,8 @@ export function useOfferDefaults(
);

return {
preassignedFareProduct: defaultPreassignedFareProduct,
preassignedFareProductAlternatives:
defaultPreassignedFareProductAlternatives,
selectableTravellers: defaultSelectableTravellers,
fromPlace: defaultFromPlace,
toPlace: defaultToPlace,
Expand Down
42 changes: 26 additions & 16 deletions src/stacks-hierarchy/Root_PurchaseOverviewScreen/use-offer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,28 @@ const getValidDurationSeconds = (offer: Offer): number | undefined =>
? secondsBetween(offer.valid_from, offer.valid_to)
: undefined;

const getOfferForTraveller = (offers: Offer[], userTypeString: string) => {
const offersForTraveller = offers.filter(
(o) => o.traveller_id === userTypeString,
);

// If there are multiple offers for the same traveller, use the cheapest one.
// This shouldn't happen in practice, but it's a sensible fallback.
const offersSortedByPrice = offersForTraveller.sort(
(a, b) =>
getCurrencyAsFloat(a.prices, 'NOK') - getCurrencyAsFloat(b.prices, 'NOK'),
);
return offersSortedByPrice[0];
};

const calculateTotalPrice = (
userProfileWithCounts: UserProfileWithCount[],
offers: Offer[],
) =>
userProfileWithCounts.reduce((total, traveller) => {
const maybeOffer = offers.find(
(o) => o.traveller_id === traveller.userTypeString,
);
const price = maybeOffer
? getCurrencyAsFloat(maybeOffer.prices, 'NOK') * traveller.count
const offer = getOfferForTraveller(offers, traveller.userTypeString);
const price = offer
? getCurrencyAsFloat(offer.prices, 'NOK') * traveller.count
: 0;
return total + price;
}, 0);
Expand All @@ -74,11 +86,9 @@ const calculateOriginalPrice = (
offers: Offer[],
) =>
userProfileWithCounts.reduce((total, traveller) => {
const maybeOffer = offers.find(
(o) => o.traveller_id === traveller.userTypeString,
);
const price = maybeOffer
? getOriginalPriceAsFloat(maybeOffer.prices, 'NOK') * traveller.count
const offer = getOfferForTraveller(offers, traveller.userTypeString);
const price = offer
? getOriginalPriceAsFloat(offer.prices, 'NOK') * traveller.count
: 0;
return total + price;
}, 0);
Expand All @@ -90,7 +100,7 @@ const mapToUserProfilesWithCountAndOffer = (
userProfileWithCounts
.map((u) => ({
...u,
offer: offers.find((o) => o.traveller_id === u.userTypeString),
offer: getOfferForTraveller(offers, u.userTypeString),
}))
.filter((u): u is UserProfileWithCountAndOffer => u.offer != null);

Expand Down Expand Up @@ -153,7 +163,7 @@ const initialState: OfferState = {

export function useOfferState(
offerEndpoint: 'zones' | 'authority' | 'stop-places',
preassignedFareProduct: PreassignedFareProduct,
preassignedFareProductAlternatives: PreassignedFareProduct[],
fromPlace: TariffZone | StopPlaceFragmentWithIsFree,
toPlace: TariffZone | StopPlaceFragmentWithIsFree,
userProfilesWithCount: UserProfileWithCount[],
Expand Down Expand Up @@ -201,7 +211,7 @@ export function useOfferState(
...placeParams,
is_on_behalf_of: isOnBehalfOf,
travellers: offerTravellers,
products: [preassignedFareProduct.id],
products: preassignedFareProductAlternatives.map((p) => p.id),
travel_date: travelDate,
};
dispatch({type: 'SEARCHING_OFFER'});
Expand Down Expand Up @@ -240,13 +250,13 @@ export function useOfferState(
[
dispatch,
userProfilesWithCount,
preassignedFareProduct,
preassignedFareProductAlternatives,
offerEndpoint,
zones,
travelDate,
fromPlace,
toPlace,
isOnBehalfOf
isOnBehalfOf,
],
);

Expand All @@ -258,7 +268,7 @@ export function useOfferState(
dispatch,
updateOffer,
userProfilesWithCount,
preassignedFareProduct,
preassignedFareProductAlternatives,
zones,
travelDate,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FareProductTypeConfig,
useFirestoreConfiguration,
findReferenceDataById,
isProductSellableInApp,
} from '@atb/configuration';
import {
listRecentFareContracts,
Expand Down Expand Up @@ -93,7 +94,7 @@ const mapBackendRecentFareContracts = (
userProfiles: UserProfile[],
): RecentFareContract | null => {
const preassignedFareProduct = findReferenceDataById(
preassignedFareProducts,
preassignedFareProducts.filter((p) => isProductSellableInApp(p)),
recentFareContract.products[0],
);

Expand Down

0 comments on commit 234e0ae

Please sign in to comment.