From 0c6a4cf186109e88f567ca7f2e9617150a5b4d25 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Mon, 12 Jan 2026 12:02:25 +0000 Subject: [PATCH] feat(BOOK-463): Fetching hotel filters from CMS and using these inside the destination pages and select hotel page * feat(BOOK-463): Fetching hotel filters from CMS and using these inside the destination pages * fix(BOOK-698): fetch hotel filters from CMS on select hotel page Approved-by: Bianca Widstam --- .../Blocks/CampaignHotelListing/index.tsx | 10 +- .../DestinationCityPage/index.tsx | 8 +- .../DestinationCountryPage/index.tsx | 8 +- .../Filter/Checkbox/checkbox.module.css | 44 ++--- .../Filter/Checkbox/index.tsx | 3 + .../DestinationFilterAndSort/Filter/index.tsx | 30 +++- .../DestinationFilterAndSort/index.tsx | 6 +- .../Filter/Checkbox/checkbox.module.css | 44 ++--- .../Filter/Checkbox/index.tsx | 3 + .../HotelFilterAndSort/Filter/index.tsx | 43 +++-- .../components/HotelFilterAndSort/index.tsx | 6 +- .../lib/trpc/memoizedRequests/index.ts | 6 + .../DestinationDataProvider/index.tsx | 19 ++- .../stores/destination-data/helper.ts | 87 +++++++++- .../stores/destination-data/index.ts | 40 ++++- .../stores/hotel-listing-data/helper.ts | 59 ++++++- .../stores/hotel-listing-data/index.ts | 76 ++++++--- .../types/providers/destination-data.ts | 17 -- .../types/providers/hotel-listing-data.ts | 4 +- .../types/stores/destination-data.ts | 17 +- .../types/stores/hotel-listing-data.ts | 23 +-- .../Filters/FilterContent/index.tsx | 7 +- .../lib/components/SelectHotel/helpers.ts | 90 +++++------ .../lib/components/SelectHotel/index.tsx | 13 +- .../lib/pages/AlternativeHotelsMapPage.tsx | 8 +- .../lib/pages/SelectHotelMapPage.tsx | 8 +- packages/booking-flow/lib/types.ts | 8 +- packages/common/constants/country.ts | 9 ++ .../graphql/Fragments/HotelFilter.graphql.ts | 2 +- .../lib/graphql/Query/HotelFilters.graphql.ts | 14 ++ .../routers/contentstack/metadata/utils.ts | 21 ++- .../schemas/destinationFilters.ts | 17 +- .../trpc/lib/routers/hotels/filters/get.ts | 6 + .../trpc/lib/routers/hotels/filters/index.ts | 6 + .../trpc/lib/routers/hotels/filters/output.ts | 61 +++++++ .../trpc/lib/routers/hotels/filters/utils.ts | 98 ++++++++++++ packages/trpc/lib/routers/hotels/query.ts | 2 + packages/trpc/lib/types/hotel.ts | 15 -- .../trpc/lib/utils/getFiltersFromHotels.ts | 150 ------------------ .../utils/mergeHotelFiltersAndSeoFilters.ts | 43 +++++ 40 files changed, 732 insertions(+), 399 deletions(-) delete mode 100644 apps/scandic-web/types/providers/destination-data.ts create mode 100644 packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts create mode 100644 packages/trpc/lib/routers/hotels/filters/get.ts create mode 100644 packages/trpc/lib/routers/hotels/filters/index.ts create mode 100644 packages/trpc/lib/routers/hotels/filters/output.ts create mode 100644 packages/trpc/lib/routers/hotels/filters/utils.ts delete mode 100644 packages/trpc/lib/utils/getFiltersFromHotels.ts create mode 100644 packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx b/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx index dbe420ae2..60fd3f2da 100644 --- a/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx @@ -4,12 +4,13 @@ import { type HotelSortItem, HotelSortOption, } from "@scandic-hotels/trpc/types/hotel" -import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels" -import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests" +import { + getHotelFilters, + getHotelsByCSFilter, +} from "@/lib/trpc/memoizedRequests" import { getIntl } from "@/i18n" -import { getLang } from "@/i18n/serverContext" import HotelListingDataProvider from "@/providers/HotelListingDataProvider" import CampaignHotelListingSkeleton from "./CampaignHotelListingSkeleton" @@ -35,14 +36,13 @@ export default async function CampaignHotelListing({ isMainBlock = false, }: CampaignHotelListingProps) { const intl = await getIntl() - const lang = await getLang() const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds }) if (!hotels.length) { return null } - const allFilters = getFiltersFromHotels(hotels, lang) + const allFilters = await getHotelFilters() const sortItems: HotelSortItem[] = [ { label: intl.formatMessage({ diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx index bb2492f12..6b0d04dd0 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx @@ -5,11 +5,11 @@ import { type HotelSortItem, HotelSortOption, } from "@scandic-hotels/trpc/types/hotel" -import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels" import { env } from "@/env/server" import { getDestinationCityPage, + getHotelFilters, getHotelsByCityIdentifier, } from "@/lib/trpc/memoizedRequests" @@ -17,7 +17,6 @@ import Breadcrumbs from "@/components/Breadcrumbs" import { SeoFilters } from "@/components/ContentType/DestinationPage/SeoFilters" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" import { getIntl } from "@/i18n" -import { getLang } from "@/i18n/serverContext" import DestinationDataProvider from "@/providers/DestinationDataProvider" import { getPathname } from "@/utils/getPathname" @@ -45,7 +44,6 @@ export default async function DestinationCityPage({ filterFromUrl, }: DestinationCityPageProps) { const intl = await getIntl() - const lang = await getLang() const pathname = await getPathname() const pageData = await getDestinationCityPage() @@ -70,7 +68,7 @@ export default async function DestinationCityPage({ const activeSeoFilter = getActiveSeoFilter(seo_filters, filterFromUrl) const allHotels = await getHotelsByCityIdentifier(cityIdentifier) - const hotelFilters = getFiltersFromHotels(allHotels, lang) + const allHotelFilters = await getHotelFilters() const sortItems: HotelSortItem[] = [ { @@ -102,7 +100,7 @@ export default async function DestinationCityPage({ }> void } @@ -18,6 +19,7 @@ export default function Checkbox({ isSelected, name, value, + isDisabled, onChange, }: CheckboxProps) { return ( @@ -25,6 +27,7 @@ export default function Checkbox({ className={styles.checkboxWrapper} isSelected={isSelected} onChange={() => onChange(value)} + isDisabled={isDisabled} > {({ isSelected }) => ( <> diff --git a/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx b/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx index 78f686cd8..8c6d5e8f8 100644 --- a/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx +++ b/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx @@ -10,13 +10,17 @@ import Checkbox from "./Checkbox" import styles from "./filter.module.css" -import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel" +import type { + HotelFilter, + HotelFilters, +} from "@scandic-hotels/trpc/routers/hotels/filters/output" interface FilterProps { - filters: CategorizedHotelFilters + filters: HotelFilters + listType: "city" | "hotel" } -export default function Filter({ filters }: FilterProps) { +export default function Filter({ filters, listType }: FilterProps) { const intl = useIntl() const { facilityFilters, surroundingsFilters } = filters const { pendingFilters, togglePendingFilter } = useDestinationDataStore( @@ -54,8 +58,9 @@ export default function Filter({ filters }: FilterProps) { {facilityFilters.map((filter) => (
  • togglePendingFilter(filter)} isSelected={ !!pendingFilters.find((pf) => pf.id === filter.id) @@ -79,8 +84,9 @@ export default function Filter({ filters }: FilterProps) { {surroundingsFilters.map((filter) => (
  • togglePendingFilter(filter)} isSelected={ !!pendingFilters.find((pf) => pf.id === filter.id) @@ -94,3 +100,17 @@ export default function Filter({ filters }: FilterProps) { ) } + +function getCheckboxLabelWithCount( + filter: HotelFilter, + listType: "city" | "hotel" +) { + if (listType === "city" && filter.cityCount) { + return `${filter.name} (${filter.cityCount})` + } + if (listType === "hotel" && filter.hotelCount) { + return `${filter.name} (${filter.hotelCount})` + } + + return filter.name +} diff --git a/apps/scandic-web/components/DestinationFilterAndSort/index.tsx b/apps/scandic-web/components/DestinationFilterAndSort/index.tsx index ee073a746..e28150e63 100644 --- a/apps/scandic-web/components/DestinationFilterAndSort/index.tsx +++ b/apps/scandic-web/components/DestinationFilterAndSort/index.tsx @@ -34,7 +34,7 @@ export default function DestinationFilterAndSort({ const intl = useIntl() const router = useRouter() const { - allFilters, + filtersWithCount, sortItems, pendingFilters, pendingSort, @@ -47,7 +47,7 @@ export default function DestinationFilterAndSort({ resetPendingValues, setIsLoading, } = useDestinationDataStore((state) => ({ - allFilters: state.allFilters, + filtersWithCount: state.filtersWithCount, sortItems: state.sortItems, pendingFilters: state.pendingFilters, pendingSort: state.pendingSort, @@ -166,7 +166,7 @@ export default function DestinationFilterAndSort({
    - +
    {pendingCount === 0 && (
    diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css index 1ee40afdd..6d99e8c01 100644 --- a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css @@ -6,36 +6,38 @@ cursor: pointer; border-radius: var(--Corner-radius-md); transition: background-color 0.3s; -} -.checkboxWrapper:hover { - background-color: var(--UI-Input-Controls-Surface-Hover); + @media (hover: hover) { + &:not([data-disabled]):hover { + background-color: var(--UI-Input-Controls-Surface-Hover); + } + } + + &[data-selected] .checkbox { + border: none; + background-color: var(--Surface-UI-Fill-Active); + } + + &[data-disabled] { + cursor: not-allowed; + + .checkbox { + border-color: var(--UI-Input-Controls-Border-Disabled); + background-color: var(--UI-Input-Controls-Surface-Disabled); + } + } } .checkbox { width: 24px; height: 24px; min-width: 24px; + background: var(--UI-Input-Controls-Surface-Normal); border: 1px solid var(--UI-Input-Controls-Border-Normal); - border-radius: var(--Corner-radius-sm); - transition: all 0.3s; + border-radius: 4px; + transition: all 200ms; display: flex; align-items: center; justify-content: center; - background-color: var(--UI-Input-Controls-Surface-Normal); -} - -.checkboxWrapper[data-selected] .checkbox { - border-color: var(--UI-Input-Controls-Fill-Selected); - background-color: var(--UI-Input-Controls-Fill-Selected); -} - -@media screen and (max-width: 767px) { - .checkboxWrapper:hover { - background-color: transparent; - } - - .checkboxWrapper[data-selected] { - background-color: transparent; - } + forced-color-adjust: none; } diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx index 74ad14a40..3bfe2e7b8 100644 --- a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx @@ -11,6 +11,7 @@ interface CheckboxProps { name: string value: string isSelected: boolean + isDisabled?: boolean onChange: (filterId: string) => void } @@ -18,12 +19,14 @@ export default function Checkbox({ isSelected, name, value, + isDisabled, onChange, }: CheckboxProps) { return ( onChange(value)} > {({ isSelected }) => ( diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx index 476294994..04753c53f 100644 --- a/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx @@ -10,10 +10,10 @@ import Checkbox from "./Checkbox" import styles from "./filter.module.css" -import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel" +import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output" interface FilterProps { - filters: CategorizedHotelFilters + filters: HotelFilters } export default function Filter({ filters }: FilterProps) { @@ -58,10 +58,17 @@ export default function Filter({ filters }: FilterProps) { {countryFilters.map((filter) => (
  • togglePendingFilter(filter.slug)} - isSelected={!!pendingFilters.find((f) => f === filter.slug)} + onChange={() => togglePendingFilter(filter)} + isSelected={ + !!pendingFilters.find((pf) => pf.id === filter.id) + } />
  • ))} @@ -81,10 +88,17 @@ export default function Filter({ filters }: FilterProps) { {facilityFilters.map((filter) => (
  • togglePendingFilter(filter.slug)} - isSelected={!!pendingFilters.find((f) => f === filter.slug)} + onChange={() => togglePendingFilter(filter)} + isSelected={ + !!pendingFilters.find((pf) => pf.id === filter.id) + } />
  • ))} @@ -104,10 +118,17 @@ export default function Filter({ filters }: FilterProps) { {surroundingsFilters.map((filter) => (
  • togglePendingFilter(filter.slug)} - isSelected={!!pendingFilters.find((f) => f === filter.slug)} + onChange={() => togglePendingFilter(filter)} + isSelected={ + !!pendingFilters.find((pf) => pf.id === filter.id) + } />
  • ))} diff --git a/apps/scandic-web/components/HotelFilterAndSort/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/index.tsx index b3cd90ed6..fc7cc4dfb 100644 --- a/apps/scandic-web/components/HotelFilterAndSort/index.tsx +++ b/apps/scandic-web/components/HotelFilterAndSort/index.tsx @@ -28,7 +28,7 @@ export default function HotelFilterAndSort() { const intl = useIntl() const router = useRouter() const { - filters, + filtersWithCount, sortItems, pendingFilters, pendingSort, @@ -39,7 +39,7 @@ export default function HotelFilterAndSort() { resetPendingValues, setIsLoading, } = useHotelListingDataStore((state) => ({ - filters: state.allFilters, + filtersWithCount: state.filtersWithCount, sortItems: state.sortItems, pendingFilters: state.pendingFilters, pendingSort: state.pendingSort, @@ -135,7 +135,7 @@ export default function HotelFilterAndSort() {
    - +
    {pendingCount === 0 && (
    diff --git a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index 1a77b3ec2..6f474239d 100644 --- a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts +++ b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts @@ -203,6 +203,12 @@ export const getHotelsByCityIdentifier = cache( }) } ) + +export const getHotelFilters = cache(async function getMemoizedHotelFilters() { + const caller = await serverClient() + return caller.hotel.filters.get() +}) + export const getAllHotelData = cache(async function getMemoizedAllHotelData() { const caller = await serverClient() return caller.hotel.hotels.getAllHotelData() diff --git a/apps/scandic-web/providers/DestinationDataProvider/index.tsx b/apps/scandic-web/providers/DestinationDataProvider/index.tsx index 4ea331129..af0fdfc71 100644 --- a/apps/scandic-web/providers/DestinationDataProvider/index.tsx +++ b/apps/scandic-web/providers/DestinationDataProvider/index.tsx @@ -8,8 +8,25 @@ import { DestinationDataContext } from "@/contexts/DestinationData" import DestinationDataProviderContent from "./Content" +import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output" +import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" +import type { DestinationFilters } from "@scandic-hotels/trpc/types/destinationsData" +import type { + HotelListingHotelData, + HotelSortItem, +} from "@scandic-hotels/trpc/types/hotel" + import type { DestinationDataStore } from "@/types/contexts/destination-data" -import type { DestinationDataProviderProps } from "@/types/providers/destination-data" + +interface DestinationDataProviderProps extends React.PropsWithChildren { + allHotels: HotelListingHotelData[] + allCities?: DestinationCityListItem[] + hotelFilters: HotelFilters + seoFilters: DestinationFilters + filterFromUrl?: string + sortItems: HotelSortItem[] + pathname: string +} export default function DestinationDataProvider({ allCities = [], diff --git a/apps/scandic-web/stores/destination-data/helper.ts b/apps/scandic-web/stores/destination-data/helper.ts index a332b1621..aa53a23a0 100644 --- a/apps/scandic-web/stores/destination-data/helper.ts +++ b/apps/scandic-web/stores/destination-data/helper.ts @@ -1,10 +1,13 @@ import { - type HotelFilter, type HotelListingHotelData, type HotelSortItem, HotelSortOption, } from "@scandic-hotels/trpc/types/hotel" +import type { + HotelFilter, + HotelFilters, +} from "@scandic-hotels/trpc/routers/hotels/filters/output" import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" import type { DestinationFilter } from "@scandic-hotels/trpc/types/destinationsData" @@ -32,7 +35,9 @@ export function getFilteredHotels( if (filters.length) { return hotels.filter(({ hotel }) => filters.every((filter) => - hotel.detailedFacilities.some((facility) => facility.id === filter.id) + hotel.detailedFacilities.some( + (facility) => facility.id === Number(filter.id) + ) ) ) } @@ -96,3 +101,81 @@ export function getActiveDestinationFilter( } return allSeoFilters.find((f) => f.filter.id === filterFromUrl.id) || null } + +export function getFiltersWithHotelAndCityCount( + hotelFilters: HotelFilters, + hotels: HotelListingHotelData[], + cities: DestinationCityListItem[] +): HotelFilters { + const flattenedFilters = Object.values(hotelFilters).flat() + const filterHotelCounts: Record = {} + const filterCityIdentifiers: Record> = {} + + hotels.forEach(({ hotel }) => { + hotel.detailedFacilities.forEach((facility) => { + filterHotelCounts[facility.id] = (filterHotelCounts[facility.id] || 0) + 1 + + if (!filterCityIdentifiers[facility.id]) { + filterCityIdentifiers[facility.id] = new Set() + } + if (hotel.cityIdentifier) { + filterCityIdentifiers[facility.id].add(hotel.cityIdentifier) + } + }) + + if (hotel.countryCode) { + filterHotelCounts[hotel.countryCode] = + (filterHotelCounts[hotel.countryCode] || 0) + 1 + + if (!filterCityIdentifiers[hotel.countryCode]) { + filterCityIdentifiers[hotel.countryCode] = new Set() + } + if (hotel.cityIdentifier) { + filterCityIdentifiers[hotel.countryCode].add(hotel.cityIdentifier) + } + } + }) + + // Count cities that match the cityIdentifiers for each filter + const filterCityCounts: Record = {} + Object.entries(filterCityIdentifiers).forEach( + ([filterId, cityIdentifiers]) => { + filterCityCounts[filterId] = cities.filter( + (city) => + city.destination_settings.city && + cityIdentifiers.has(city.destination_settings.city) + ).length + } + ) + + return flattenedFilters.reduce( + (acc, filter) => { + if (filter.filterType === "facility") { + acc.facilityFilters.push({ + ...filter, + hotelCount: filterHotelCounts[filter.id] ?? 0, + cityCount: filterCityCounts[filter.id] ?? 0, + }) + } else if (filter.filterType === "surroundings") { + acc.surroundingsFilters.push({ + ...filter, + hotelCount: filterHotelCounts[filter.id] ?? 0, + cityCount: filterCityCounts[filter.id] ?? 0, + }) + } else if (filter.filterType === "country") { + acc.countryFilters.push({ + ...filter, + filterType: "country", + hotelCount: filterHotelCounts[filter.id] ?? 0, + cityCount: filterCityCounts[filter.id] ?? 0, + }) + } + return acc + }, + { + facilityFilters: [], + surroundingsFilters: [], + countryFilters: [], + } as HotelFilters + ) +} diff --git a/apps/scandic-web/stores/destination-data/index.ts b/apps/scandic-web/stores/destination-data/index.ts index 06f1d18ef..9a3006b9c 100644 --- a/apps/scandic-web/stores/destination-data/index.ts +++ b/apps/scandic-web/stores/destination-data/index.ts @@ -2,8 +2,8 @@ import { produce } from "immer" import { useContext } from "react" import { create, useStore } from "zustand" -import { mergeHotelFiltersAndSeoFilters } from "@scandic-hotels/trpc/utils/getFiltersFromHotels" import { getSortedCities } from "@scandic-hotels/trpc/utils/getSortedCities" +import { mergeHotelFiltersAndSeoFilters } from "@scandic-hotels/trpc/utils/mergeHotelFiltersAndSeoFilters" import { DestinationDataContext } from "@/contexts/DestinationData" import { @@ -16,12 +16,13 @@ import { getBasePathNameWithoutFilters, getFilteredCities, getFilteredHotels, + getFiltersWithHotelAndCityCount, getSortedHotels, isValidSortOption, } from "./helper" +import type { HotelFilter } from "@scandic-hotels/trpc/routers/hotels/filters/output" import type { DestinationFilter } from "@scandic-hotels/trpc/types/destinationsData" -import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel" import type { DestinationDataState, @@ -39,9 +40,10 @@ export function createDestinationDataStore({ }: InitialState) { const defaultSort = sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value + const allFilters = mergeHotelFiltersAndSeoFilters(hotelFilters, seoFilters) const allSeoFilters = Object.values(seoFilters).flat() - const allFlattenedFilters = Object.values(allFilters).flat() + const allFlattenedFilters = Object.values(allFilters).flat() const allFilterSlugs = allFlattenedFilters.map((filter) => filter.slug) const activeFilters: HotelFilter[] = [] let activeSeoFilter: DestinationFilter | null = null @@ -75,6 +77,12 @@ export function createDestinationDataStore({ const filteredCities = getFilteredCities(filteredHotels, allCities) const activeCities = getSortedCities(filteredCities, activeSort) + const filtersWithCount = getFiltersWithHotelAndCityCount( + allFilters, + filteredHotels, + filteredCities + ) + return create((set) => ({ actions: { updateActiveFiltersAndSort(filterSlugs, sort, filterSlugFromUrl) { @@ -175,9 +183,16 @@ export function createDestinationDataStore({ ? getFilteredCities(pendingHotels, state.allCities) : [] + const pendingFiltersWithCount = getFiltersWithHotelAndCityCount( + state.allFilters, + pendingHotels, + pendingCities + ) + state.pendingFilters = filters state.pendingHotelCount = pendingHotels.length state.pendingCityCount = pendingCities.length + state.filtersWithCount = pendingFiltersWithCount }) ) }, @@ -187,6 +202,11 @@ export function createDestinationDataStore({ state.pendingFilters = [] state.pendingHotelCount = state.allHotels.length state.pendingCityCount = state.allCities.length + state.filtersWithCount = getFiltersWithHotelAndCityCount( + state.allFilters, + state.allHotels, + state.allCities + ) }) ) }, @@ -197,6 +217,19 @@ export function createDestinationDataStore({ state.pendingSort = state.activeSort state.pendingHotelCount = state.activeHotels.length state.pendingCityCount = state.activeCities.length + const filteredHotels = getFilteredHotels( + state.allHotels, + state.activeFilters + ) + const filteredCities = getFilteredCities( + filteredHotels, + state.allCities + ) + state.filtersWithCount = getFiltersWithHotelAndCityCount( + state.allFilters, + filteredHotels, + filteredCities + ) }) ) }, @@ -213,6 +246,7 @@ export function createDestinationDataStore({ activeFilters, pendingFilters: activeFilters, allFilters, + filtersWithCount, activeSeoFilter, filterFromUrl, basePathnameWithoutFilters, diff --git a/apps/scandic-web/stores/hotel-listing-data/helper.ts b/apps/scandic-web/stores/hotel-listing-data/helper.ts index 9900dae1e..fd3362d41 100644 --- a/apps/scandic-web/stores/hotel-listing-data/helper.ts +++ b/apps/scandic-web/stores/hotel-listing-data/helper.ts @@ -4,6 +4,11 @@ import { HotelSortOption, } from "@scandic-hotels/trpc/types/hotel" +import type { + HotelFilter, + HotelFilters, +} from "@scandic-hotels/trpc/routers/hotels/filters/output" + const HOTEL_SORTING_STRATEGIES: Partial< Record< HotelSortOption, @@ -20,16 +25,15 @@ const HOTEL_SORTING_STRATEGIES: Partial< export function getFilteredHotels( hotels: HotelListingHotelData[], - filters: string[] + filters: HotelFilter[] ) { if (filters.length) { return hotels.filter(({ hotel }) => filters.every((filter) => { const matchesFacility = hotel.detailedFacilities.some( - (facility) => facility.slug === filter + (facility) => facility.id === Number(filter.id) ) - const matchesCountry = - hotel.countryCode.toLowerCase() === filter.toLowerCase() + const matchesCountry = hotel.countryCode === filter.id return matchesFacility || matchesCountry }) ) @@ -51,3 +55,50 @@ export function isValidSortOption( ): value is HotelSortOption { return sortItems.map((item) => item.value).includes(value as HotelSortOption) } + +export function getFiltersWithHotelCount( + hotelFilters: HotelFilters, + hotels: HotelListingHotelData[] +): HotelFilters { + const flattenedFilters = Object.values(hotelFilters).flat() + const filterHotelCounts: Record = {} + + hotels.forEach(({ hotel }) => { + hotel.detailedFacilities.forEach((facility) => { + filterHotelCounts[facility.id] = (filterHotelCounts[facility.id] || 0) + 1 + }) + + if (hotel.countryCode) { + filterHotelCounts[hotel.countryCode] = + (filterHotelCounts[hotel.countryCode] || 0) + 1 + } + }) + + return flattenedFilters.reduce( + (acc, filter) => { + if (filter.filterType === "facility") { + acc.facilityFilters.push({ + ...filter, + hotelCount: filterHotelCounts[filter.id] ?? 0, + }) + } else if (filter.filterType === "surroundings") { + acc.surroundingsFilters.push({ + ...filter, + hotelCount: filterHotelCounts[filter.id] ?? 0, + }) + } else if (filter.filterType === "country") { + acc.countryFilters.push({ + ...filter, + filterType: "country", + hotelCount: filterHotelCounts[filter.id] ?? 0, + }) + } + return acc + }, + { + facilityFilters: [], + surroundingsFilters: [], + countryFilters: [], + } as HotelFilters + ) +} diff --git a/apps/scandic-web/stores/hotel-listing-data/index.ts b/apps/scandic-web/stores/hotel-listing-data/index.ts index ae4fe91e1..585e7efc3 100644 --- a/apps/scandic-web/stores/hotel-listing-data/index.ts +++ b/apps/scandic-web/stores/hotel-listing-data/index.ts @@ -8,9 +8,14 @@ import { trackSortingChangeEvent, } from "@/utils/tracking/destinationPage" -import { getFilteredHotels, getSortedHotels, isValidSortOption } from "./helper" +import { + getFilteredHotels, + getFiltersWithHotelCount, + getSortedHotels, + isValidSortOption, +} from "./helper" -import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel" +import type { HotelFilter } from "@scandic-hotels/trpc/routers/hotels/filters/output" import type { HotelListingDataState, @@ -25,11 +30,10 @@ export function createHotelListingDataStore({ }: InitialState) { const defaultSort = sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value - const allFilterSlugs = Object.values(allFilters).flatMap( - (filter: HotelFilter[]) => filter.map((f) => f.slug) - ) + const allFlattenedFilters = Object.values(allFilters).flat() + const allFilterSlugs = allFlattenedFilters.map((filter) => filter.slug) - const activeFilters: string[] = [] + const activeFilters: HotelFilter[] = [] let activeSort = defaultSort if (searchParams) { @@ -43,8 +47,9 @@ export function createHotelListingDataStore({ if (filterParam) { const filters = filterParam.split(",") filters.forEach((filter) => { - if (allFilterSlugs.includes(filter)) { - activeFilters.push(filter) + const filterFromUrl = allFlattenedFilters.find((f) => f.slug === filter) + if (filterFromUrl) { + activeFilters.push(filterFromUrl) } }) } @@ -52,15 +57,20 @@ export function createHotelListingDataStore({ const filteredHotels = getFilteredHotels(allHotels, activeFilters) const activeHotels = getSortedHotels(filteredHotels, activeSort) + const filtersWithCount = getFiltersWithHotelCount(allFilters, filteredHotels) + return create((set) => ({ actions: { - updateActiveFiltersAndSort(filters, sort) { + updateActiveFiltersAndSort(filterSlugs, sort) { return set( produce((state: HotelListingDataState) => { const newSort = sort && isValidSortOption(sort, state.sortItems) ? sort : state.defaultSort + const filters = allFlattenedFilters.filter((filter) => + filterSlugs.includes(filter.slug) + ) const filteredHotels = getFilteredHotels(state.allHotels, filters) const sortedHotels = getSortedHotels(filteredHotels, newSort) @@ -71,16 +81,22 @@ export function createHotelListingDataStore({ if ( JSON.stringify(filters) !== JSON.stringify(state.activeFilters) ) { - const facilityFiltersUsed = filters.filter((f) => - state.allFilters.facilityFilters - .map((ff) => ff.slug) - .includes(f) - ) - const surroundingsFiltersUsed = filters.filter((f) => - state.allFilters.surroundingsFilters - .map((sf) => sf.slug) - .includes(f) - ) + const facilityFiltersUsed = filters + .filter( + (f) => + !!state.allFilters.facilityFilters.find( + (ff) => ff.id === f.id + ) + ) + .map((f) => f.slug) + const surroundingsFiltersUsed = filters + .filter( + (f) => + !!state.allFilters.surroundingsFilters.find( + (sf) => sf.id === f.id + ) + ) + .map((f) => f.slug) trackFilterChangeEvent( facilityFiltersUsed, @@ -116,14 +132,23 @@ export function createHotelListingDataStore({ togglePendingFilter(filter) { return set( produce((state: HotelListingDataState) => { - const isActive = state.pendingFilters.includes(filter) + const filterId = filter.id + const isActive = !!state.pendingFilters.find( + (pf) => pf.id === filterId + ) const filters = isActive - ? state.pendingFilters.filter((f) => f !== filter) + ? state.pendingFilters.filter((pf) => pf.id !== filterId) : [...state.pendingFilters, filter] const pendingHotels = getFilteredHotels(state.allHotels, filters) + const pendingFiltersWithCount = getFiltersWithHotelCount( + state.allFilters, + pendingHotels + ) + state.pendingFilters = filters state.pendingHotelCount = pendingHotels.length + state.filtersWithCount = pendingFiltersWithCount }) ) }, @@ -132,6 +157,10 @@ export function createHotelListingDataStore({ produce((state: HotelListingDataState) => { state.pendingFilters = [] state.pendingHotelCount = state.allHotels.length + state.filtersWithCount = getFiltersWithHotelCount( + state.allFilters, + state.allHotels + ) }) ) }, @@ -141,6 +170,10 @@ export function createHotelListingDataStore({ state.pendingFilters = state.activeFilters state.pendingSort = state.activeSort state.pendingHotelCount = state.activeHotels.length + state.filtersWithCount = getFiltersWithHotelCount( + state.allFilters, + state.activeHotels + ) }) ) }, @@ -154,6 +187,7 @@ export function createHotelListingDataStore({ activeFilters, pendingFilters: activeFilters, allFilters, + filtersWithCount, allFilterSlugs, sortItems, isLoading: false, diff --git a/apps/scandic-web/types/providers/destination-data.ts b/apps/scandic-web/types/providers/destination-data.ts deleted file mode 100644 index c2bf7355f..000000000 --- a/apps/scandic-web/types/providers/destination-data.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" -import type { DestinationFilters } from "@scandic-hotels/trpc/types/destinationsData" -import type { - CategorizedHotelFilters, - HotelListingHotelData, - HotelSortItem, -} from "@scandic-hotels/trpc/types/hotel" - -export interface DestinationDataProviderProps extends React.PropsWithChildren { - allHotels: HotelListingHotelData[] - allCities?: DestinationCityListItem[] - hotelFilters: CategorizedHotelFilters - seoFilters: DestinationFilters - filterFromUrl?: string - sortItems: HotelSortItem[] - pathname: string -} diff --git a/apps/scandic-web/types/providers/hotel-listing-data.ts b/apps/scandic-web/types/providers/hotel-listing-data.ts index bc52c83b9..e181008a8 100644 --- a/apps/scandic-web/types/providers/hotel-listing-data.ts +++ b/apps/scandic-web/types/providers/hotel-listing-data.ts @@ -1,11 +1,11 @@ +import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output" import type { - CategorizedHotelFilters, HotelListingHotelData, HotelSortItem, } from "@scandic-hotels/trpc/types/hotel" export interface HotelListingDataProviderProps extends React.PropsWithChildren { allHotels: HotelListingHotelData[] - allFilters: CategorizedHotelFilters + allFilters: HotelFilters sortItems: HotelSortItem[] } diff --git a/apps/scandic-web/types/stores/destination-data.ts b/apps/scandic-web/types/stores/destination-data.ts index 89c9ee600..5a576b239 100644 --- a/apps/scandic-web/types/stores/destination-data.ts +++ b/apps/scandic-web/types/stores/destination-data.ts @@ -1,11 +1,13 @@ +import type { + HotelFilter, + HotelFilters, +} from "@scandic-hotels/trpc/routers/hotels/filters/output" import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" import type { DestinationFilter, DestinationFilters, } from "@scandic-hotels/trpc/types/destinationsData" import type { - CategorizedHotelFilters, - HotelFilter, HotelListingHotelData, HotelSortItem, HotelSortOption, @@ -39,17 +41,20 @@ export interface DestinationDataState { filterFromUrl: HotelFilter | null pendingHotelCount: number pendingCityCount: number - allFilters: CategorizedHotelFilters + allFilters: HotelFilters + filtersWithCount: HotelFilters activeSeoFilter: DestinationFilter | null basePathnameWithoutFilters: string sortItems: HotelSortItem[] isLoading: boolean } -export interface InitialState - extends Pick { +export interface InitialState extends Pick< + DestinationDataState, + "allHotels" | "allCities" | "sortItems" +> { pathname: string searchParams: ReadonlyURLSearchParams - hotelFilters: CategorizedHotelFilters + hotelFilters: HotelFilters seoFilters: DestinationFilters } diff --git a/apps/scandic-web/types/stores/hotel-listing-data.ts b/apps/scandic-web/types/stores/hotel-listing-data.ts index b640774b1..b9aa68d5b 100644 --- a/apps/scandic-web/types/stores/hotel-listing-data.ts +++ b/apps/scandic-web/types/stores/hotel-listing-data.ts @@ -1,5 +1,8 @@ import type { - CategorizedHotelFilters, + HotelFilter, + HotelFilters, +} from "@scandic-hotels/trpc/routers/hotels/filters/output" +import type { HotelListingHotelData, HotelSortItem, HotelSortOption, @@ -9,7 +12,7 @@ import type { ReadonlyURLSearchParams } from "next/navigation" interface Actions { updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void setPendingSort: (sort: HotelSortOption) => void - togglePendingFilter: (filter: string) => void + togglePendingFilter: (filter: HotelFilter) => void clearPendingFilters: () => void resetPendingValues: () => void setIsLoading: (isLoading: boolean) => void @@ -22,19 +25,19 @@ export interface HotelListingDataState { pendingSort: HotelSortOption activeSort: HotelSortOption defaultSort: HotelSortOption - pendingFilters: string[] - activeFilters: string[] + pendingFilters: HotelFilter[] + activeFilters: HotelFilter[] pendingHotelCount: number - allFilters: CategorizedHotelFilters + allFilters: HotelFilters + filtersWithCount: HotelFilters allFilterSlugs: string[] sortItems: HotelSortItem[] isLoading: boolean } -export interface InitialState - extends Pick< - HotelListingDataState, - "allHotels" | "sortItems" | "allFilters" - > { +export interface InitialState extends Pick< + HotelListingDataState, + "allHotels" | "sortItems" | "allFilters" +> { searchParams: ReadonlyURLSearchParams } diff --git a/packages/booking-flow/lib/components/SelectHotel/Filters/FilterContent/index.tsx b/packages/booking-flow/lib/components/SelectHotel/Filters/FilterContent/index.tsx index 9fd75fa09..c3a224a1a 100644 --- a/packages/booking-flow/lib/components/SelectHotel/Filters/FilterContent/index.tsx +++ b/packages/booking-flow/lib/components/SelectHotel/Filters/FilterContent/index.tsx @@ -12,7 +12,10 @@ import FilterCheckbox from "./FilterCheckbox" import styles from "./filterContent.module.css" -import type { CategorizedHotelFilters, HotelFilter } from "../../../../types" +import type { + CategorizedHotelFilters, + SelectHotelFilter, +} from "../../../../types" interface FilterContentProps { filters: CategorizedHotelFilters @@ -75,7 +78,7 @@ export default function FilterContent({ return null } - function filterOutput(filters: HotelFilter[]) { + function filterOutput(filters: SelectHotelFilter[]) { return filters.map((filter) => { const relevantIds = showOnlyBookingCodeRates ? filter.bookingCodeFilteredIds diff --git a/packages/booking-flow/lib/components/SelectHotel/helpers.ts b/packages/booking-flow/lib/components/SelectHotel/helpers.ts index f0bf4b99f..a75d8e651 100644 --- a/packages/booking-flow/lib/components/SelectHotel/helpers.ts +++ b/packages/booking-flow/lib/components/SelectHotel/helpers.ts @@ -1,5 +1,6 @@ import { dt } from "@scandic-hotels/common/dt" import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel" +import { getHotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/utils" import { generateChildrenString } from "@scandic-hotels/trpc/routers/hotels/helpers" import { serverClient } from "../../trpc" @@ -18,7 +19,7 @@ import type { Location, } from "@scandic-hotels/trpc/types/locations" -import type { CategorizedHotelFilters, HotelFilter } from "../../types" +import type { CategorizedHotelFilters, SelectHotelFilter } from "../../types" type AvailabilityInput = { cityId: string @@ -273,50 +274,38 @@ export async function getHotels({ return hotels } -const hotelSurroundingsFilterNames = [ - "Hotel surroundings", - "Hotel omgivelser", - "Hotelumgebung", - "Hotellia lähellä", - "Hotellomgivelser", - "Omgivningar", -] - -const hotelFacilitiesFilterNames = [ - "Hotel facilities", - "Hotellfaciliteter", - "Hotelfaciliteter", - "Hotel faciliteter", - "Hotel-Infos", - "Hotellin palvelut", -] - -export function getFiltersFromHotels( +export async function fetchHotelFiltersAndMapToCategorizedFilters( hotels: HotelResponse[], - showBookingCodeFilter: boolean -): CategorizedHotelFilters { + showBookingCodeFilter: boolean, + lang: Lang +): Promise { const defaultFilters = { facilityFilters: [], surroundingsFilters: [] } if (!hotels.length) { return defaultFilters } + const { countryFilters, ...hotelFilters } = await getHotelFilters(lang) + const allFlattenedFilters = Object.values(hotelFilters).flat() + const filters = hotels.flatMap(({ hotel, availability }) => { + const hotelFilterData = allFlattenedFilters.map((filter) => { + const hotelHasFilter = hotel.detailedFacilities.some( + (facility) => facility.id.toString() === filter.id + ) + return { + ...filter, + hotelId: hotelHasFilter ? hotel.operaId : null, + hotelIds: hotelHasFilter ? [hotel.operaId] : [], + bookingCodeFilteredIds: + (availability.bookingCode || !showBookingCodeFilter) && hotelHasFilter + ? [hotel.operaId] + : [], + } + }) - const filters = hotels.flatMap(({ hotel, availability }) => - hotel.detailedFacilities.map( - (facility) => - { - ...facility, - hotelId: hotel.operaId, - hotelIds: [hotel.operaId], - bookingCodeFilteredIds: - availability.bookingCode || !showBookingCodeFilter - ? [hotel.operaId] - : [], - } - ) - ) + return hotelFilterData + }) const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))] - const filterList: HotelFilter[] = uniqueFilterIds + const filterList: SelectHotelFilter[] = uniqueFilterIds .map((filterId) => { const filter = filters.find((f) => f.id === filterId) @@ -324,7 +313,9 @@ export function getFiltersFromHotels( if (filter) { const matchingFilters = filters.filter((f) => f.id === filterId) - filter.hotelIds = matchingFilters.map((f) => f.hotelId) + filter.hotelIds = matchingFilters + .map((f) => f.hotelId) + .filter((id) => id !== null) filter.bookingCodeFilteredIds = [ ...new Set( matchingFilters.flatMap((f) => f.bookingCodeFilteredIds ?? []) @@ -333,18 +324,17 @@ export function getFiltersFromHotels( } return filter }) - .filter((filter): filter is HotelFilter => filter !== undefined) + .filter((filter): filter is SelectHotelFilter => filter !== undefined) .sort((a, b) => b.sortOrder - a.sortOrder) - return filterList.reduce((filters, filter) => { - if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter)) { - filters.surroundingsFilters.push(filter) - } - - if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter)) { - filters.facilityFilters.push(filter) - } - - return filters - }, defaultFilters) + const facilityFilters = filterList.filter( + (filter) => filter.filterType === "facility" + ) + const surroundingsFilters = filterList.filter( + (filter) => filter.filterType === "surroundings" + ) + return { + facilityFilters, + surroundingsFilters, + } } diff --git a/packages/booking-flow/lib/components/SelectHotel/index.tsx b/packages/booking-flow/lib/components/SelectHotel/index.tsx index 60a81a6f4..a1ab40a09 100644 --- a/packages/booking-flow/lib/components/SelectHotel/index.tsx +++ b/packages/booking-flow/lib/components/SelectHotel/index.tsx @@ -9,7 +9,10 @@ import BookingCodeFilter from "../BookingCodeFilter" import HotelCardListing from "../HotelCardListing" import { StaticMap } from "../StaticMap" import HotelFilter from "./Filters/HotelFilter" -import { getFiltersFromHotels, type HotelResponse } from "./helpers" +import { + fetchHotelFiltersAndMapToCategorizedFilters, + type HotelResponse, +} from "./helpers" import HotelCount from "./HotelCount" import HotelSorter from "./HotelSorter" import { MapWithButtonWrapper } from "./MapWithButtonWrapper" @@ -35,7 +38,7 @@ interface SelectHotelProps { topSlot?: ReactNode } -export function SelectHotel({ +export async function SelectHotel({ bookingCode, city, hotels, @@ -61,7 +64,11 @@ export function SelectHotel({ const showBookingCodeFilter = isBookingCodeRateAvailable && !isSpecialRate - const filterList = getFiltersFromHotels(hotels, showBookingCodeFilter) + const filterList = await fetchHotelFiltersAndMapToCategorizedFilters( + hotels, + showBookingCodeFilter, + lang + ) return ( <> diff --git a/packages/booking-flow/lib/pages/AlternativeHotelsMapPage.tsx b/packages/booking-flow/lib/pages/AlternativeHotelsMapPage.tsx index 12a617031..6b131ce93 100644 --- a/packages/booking-flow/lib/pages/AlternativeHotelsMapPage.tsx +++ b/packages/booking-flow/lib/pages/AlternativeHotelsMapPage.tsx @@ -8,7 +8,7 @@ import { env } from "../../env/server" import { BookingFlowConfig } from "../bookingFlowConfig/bookingFlowConfig" import { MapContainer } from "../components/MapContainer" import { - getFiltersFromHotels, + fetchHotelFiltersAndMapToCategorizedFilters, getHotels, } from "../components/SelectHotel/helpers" import { @@ -108,7 +108,11 @@ export async function AlternativeHotelsMapPage({ config, }) - const filterList = getFiltersFromHotels(hotels, isBookingCodeRateAvailable) + const filterList = await fetchHotelFiltersAndMapToCategorizedFilters( + hotels, + isBookingCodeRateAvailable, + lang + ) return ( diff --git a/packages/booking-flow/lib/pages/SelectHotelMapPage.tsx b/packages/booking-flow/lib/pages/SelectHotelMapPage.tsx index bc8c8b92d..5c65eacca 100644 --- a/packages/booking-flow/lib/pages/SelectHotelMapPage.tsx +++ b/packages/booking-flow/lib/pages/SelectHotelMapPage.tsx @@ -9,7 +9,7 @@ import { env } from "../../env/server" import { BookingFlowConfig } from "../bookingFlowConfig/bookingFlowConfig" import { MapContainer } from "../components/MapContainer" import { - getFiltersFromHotels, + fetchHotelFiltersAndMapToCategorizedFilters, getHotels, } from "../components/SelectHotel/helpers" import { @@ -109,7 +109,11 @@ export async function SelectHotelMapPage({ config, }) - const filterList = getFiltersFromHotels(hotels, isBookingCodeRateAvailable) + const filterList = await fetchHotelFiltersAndMapToCategorizedFilters( + hotels, + isBookingCodeRateAvailable, + lang + ) const suspenseKey = stringify(searchParams) diff --git a/packages/booking-flow/lib/types.ts b/packages/booking-flow/lib/types.ts index 2127c1858..420531584 100644 --- a/packages/booking-flow/lib/types.ts +++ b/packages/booking-flow/lib/types.ts @@ -1,14 +1,14 @@ -import type { Hotel } from "@scandic-hotels/trpc/types/hotel" +import type { HotelFilter } from "@scandic-hotels/trpc/routers/hotels/filters/output" export type NextSearchParams = { [key: string]: string | string[] | undefined } -export type HotelFilter = Hotel["detailedFacilities"][number] & { +export type SelectHotelFilter = HotelFilter & { hotelId: string hotelIds: string[] bookingCodeFilteredIds: string[] } export type CategorizedHotelFilters = { - facilityFilters: HotelFilter[] - surroundingsFilters: HotelFilter[] + facilityFilters: SelectHotelFilter[] + surroundingsFilters: SelectHotelFilter[] } diff --git a/packages/common/constants/country.ts b/packages/common/constants/country.ts index 3ccdd1e4d..3cf819436 100644 --- a/packages/common/constants/country.ts +++ b/packages/common/constants/country.ts @@ -6,3 +6,12 @@ export enum Country { Poland = "Poland", Sweden = "Sweden", } + +export const CountryCode: Record = { + [Country.Denmark]: "DK", + [Country.Finland]: "FI", + [Country.Germany]: "DE", + [Country.Norway]: "NO", + [Country.Poland]: "PL", + [Country.Sweden]: "SE", +} diff --git a/packages/trpc/lib/graphql/Fragments/HotelFilter.graphql.ts b/packages/trpc/lib/graphql/Fragments/HotelFilter.graphql.ts index 2af30abca..536e51a11 100644 --- a/packages/trpc/lib/graphql/Fragments/HotelFilter.graphql.ts +++ b/packages/trpc/lib/graphql/Fragments/HotelFilter.graphql.ts @@ -8,8 +8,8 @@ export const HotelFilter = gql` facility_id category slug + sort_order } - ${System} ` export const HotelFilterRef = gql` diff --git a/packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts b/packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts new file mode 100644 index 000000000..b5f88eac3 --- /dev/null +++ b/packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts @@ -0,0 +1,14 @@ +import { gql } from "graphql-tag" + +import { HotelFilter } from "../Fragments/HotelFilter.graphql" + +export const GetHotelFilters = gql` + query GetHotelFilters($locale: String!) { + all_hotel_filter(locale: $locale) { + items { + ...HotelFilter + } + } + } + ${HotelFilter} +` diff --git a/packages/trpc/lib/routers/contentstack/metadata/utils.ts b/packages/trpc/lib/routers/contentstack/metadata/utils.ts index d428225fe..c85b4859d 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/utils.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/utils.ts @@ -1,16 +1,13 @@ import { ApiCountry } from "../../../types/country" import { HotelSortOption } from "../../../types/hotel" -import { - getFiltersFromHotels, - mergeHotelFiltersAndSeoFilters, -} from "../../../utils/getFiltersFromHotels" import { getSortedCities } from "../../../utils/getSortedCities" +import { mergeHotelFiltersAndSeoFilters } from "../../../utils/mergeHotelFiltersAndSeoFilters" +import { getHotelFilters } from "../../hotels/filters/utils" import { getCityByCityIdentifier, getHotelIdsByCityIdentifier, } from "../../hotels/services/getCityByCityIdentifier" import { getHotelIdsByCountry } from "../../hotels/services/getHotelIdsByCountry" -import { getHotelsByHotelIds } from "../../hotels/services/getHotelsByHotelIds" import { getCityPages } from "../destinationCountryPage/utils" import { transformDestinationFiltersResponse } from "../schemas/destinationFilters" @@ -62,11 +59,9 @@ export async function getCityData( serviceToken ) - const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) - let filterType if (filter) { - const hotelFilters = getFiltersFromHotels(hotels, lang) + const hotelFilters = await getHotelFilters(lang) const allFilters = mergeHotelFiltersAndSeoFilters( hotelFilters, seoFilters @@ -104,6 +99,7 @@ export async function getCountryData( lang: Lang ) { const country = data.destination_settings?.country + const seoFilters = transformDestinationFiltersResponse(data.seo_filters) const filter = input.filterFromUrl if (country) { @@ -117,10 +113,13 @@ export async function getCountryData( serviceToken, }) - const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) - if (filter) { - const allFilters = getFiltersFromHotels(hotels, lang) + const hotelFilters = await getHotelFilters(lang) + const allFilters = mergeHotelFiltersAndSeoFilters( + hotelFilters, + seoFilters + ) + const facilityFilter = allFilters.facilityFilters.find( (f) => f.slug === filter ) diff --git a/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts b/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts index efd0c3973..ebd573e85 100644 --- a/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts +++ b/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts @@ -1,10 +1,10 @@ import { z } from "zod" -import { FacilityEnum } from "@scandic-hotels/common/constants/facilities" import { isDefined } from "@scandic-hotels/common/utils/isDefined" import { DestinationFilterBlocksEnum } from "../../../types/destinationsData" import { discriminatedUnionArray } from "../../../utils/discriminatedUnion" +import { hotelFilterSchema } from "../../hotels/filters/output" import { accordionSchema } from "./blocks/accordion" import { contentSchema } from "./blocks/content" import { systemSchema } from "./system" @@ -37,12 +37,7 @@ export const destinationFilterSchema = z.object({ filterConnection: z.object({ edges: z.array( z.object({ - node: z.object({ - title: z.string(), - facility_id: z.nativeEnum(FacilityEnum).catch(FacilityEnum.UNKNOWN), - category: z.string(), - slug: z.string(), - }), + node: hotelFilterSchema, }) ), }), @@ -84,13 +79,7 @@ export function transformDestinationFiltersResponse( heading, preamble, blocks, - filter: { - id: filter.facility_id, - name: filter.title, - filterType: filter.category, - slug: filter.slug, - sortOrder: 0, - }, + filter, } }) .filter(isDefined) diff --git a/packages/trpc/lib/routers/hotels/filters/get.ts b/packages/trpc/lib/routers/hotels/filters/get.ts new file mode 100644 index 000000000..5fb6aa522 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/filters/get.ts @@ -0,0 +1,6 @@ +import { contentstackBaseProcedure } from "../../../procedures" +import { getHotelFilters } from "./utils" + +export const get = contentstackBaseProcedure.query(async ({ ctx }) => { + return getHotelFilters(ctx.lang) +}) diff --git a/packages/trpc/lib/routers/hotels/filters/index.ts b/packages/trpc/lib/routers/hotels/filters/index.ts new file mode 100644 index 000000000..ec53b8024 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/filters/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../.." +import { get } from "./get" + +export const filtersRouter = router({ + get, +}) diff --git a/packages/trpc/lib/routers/hotels/filters/output.ts b/packages/trpc/lib/routers/hotels/filters/output.ts new file mode 100644 index 000000000..7d5d0ba4d --- /dev/null +++ b/packages/trpc/lib/routers/hotels/filters/output.ts @@ -0,0 +1,61 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { getCountryFilters } from "./utils" + +export const FILTER_TYPES = ["surroundings", "facility", "country"] as const +export type FilterType = (typeof FILTER_TYPES)[number] + +export const hotelFilterSchema = z + .object({ + category: z.enum(FILTER_TYPES), + facility_id: z.number().transform((id) => id.toString()), + slug: z.string(), + title: z.string(), + sort_order: z.number().nullish(), + }) + .transform(({ facility_id, title, category, slug, sort_order }) => ({ + id: facility_id, + name: title, + filterType: category, + slug, + sortOrder: sort_order ?? 0, + hotelCount: 0, + cityCount: 0, + })) + +export const hotelFiltersSchema = z + .object({ + all_hotel_filter: z.object({ + items: z.array(hotelFilterSchema), + }), + lang: z.nativeEnum(Lang), + }) + .transform((data) => { + const filters = data.all_hotel_filter.items + + if (!filters?.length) { + return { + facilityFilters: [], + surroundingsFilters: [], + countryFilters: [], + } + } + + const facilityFilters = filters.filter( + (filter) => filter.filterType === "facility" + ) + const surroundingsFilters = filters.filter( + (filter) => filter.filterType === "surroundings" + ) + + return { + facilityFilters, + surroundingsFilters, + countryFilters: getCountryFilters(data.lang), + } + }) + +export type HotelFilters = z.output +export type HotelFilter = HotelFilters[keyof HotelFilters][number] diff --git a/packages/trpc/lib/routers/hotels/filters/utils.ts b/packages/trpc/lib/routers/hotels/filters/utils.ts new file mode 100644 index 000000000..5c509944b --- /dev/null +++ b/packages/trpc/lib/routers/hotels/filters/utils.ts @@ -0,0 +1,98 @@ +import { CountryCode } from "@scandic-hotels/common/constants/country" +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { GetHotelFilters } from "../../../graphql/Query/HotelFilters.graphql" +import { request } from "../../../graphql/request" +import { hotelFiltersSchema } from "./output" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +import type { FilterType, HotelFilter } from "./output" + +export async function getHotelFilters(lang: Lang) { + const cacheClient = await getCacheClient() + const cacheKey = `${lang}:getHotelFilters` + + return await cacheClient.cacheOrGet( + cacheKey, + async () => { + const getHotelFiltersCounter = createCounter( + "trpc.contentstack.hotelFilters.getAll" + ) + const metricsGetHotelFilters = getHotelFiltersCounter.init({ lang }) + + metricsGetHotelFilters.start() + + const response = await request( + GetHotelFilters, + { locale: lang }, + { key: `${lang}:hotel_filters`, ttl: "1d" } + ) + + if (!response.data) { + metricsGetHotelFilters.noDataError() + return { + facilityFilters: [], + surroundingsFilters: [], + countryFilters: [], + } + } + + const validatedResponse = hotelFiltersSchema.safeParse({ + ...response.data, + lang, + }) + if (!validatedResponse.success) { + metricsGetHotelFilters.validationError(validatedResponse.error) + return { + facilityFilters: [], + surroundingsFilters: [], + countryFilters: [], + } + } + + metricsGetHotelFilters.success() + + const { facilityFilters, surroundingsFilters, countryFilters } = + validatedResponse.data + return { + facilityFilters: sortFilters(facilityFilters), + surroundingsFilters: sortFilters(surroundingsFilters), + countryFilters: sortFilters(countryFilters), + } + }, + "1d" + ) +} + +export function getCountryFilters(lang: Lang) { + const countryCodes = Object.values(CountryCode) + const localizedCountries = new Intl.DisplayNames([lang], { type: "region" }) + return [...countryCodes] + .map((countryCode) => { + const localizedCountryName = localizedCountries.of(countryCode) + + return localizedCountryName + ? { + id: countryCode, + name: localizedCountryName, + slug: countryCode.toLowerCase(), + filterType: "country" as FilterType, + sortOrder: 0, + hotelCount: 0, + cityCount: 0, + } + : null + }) + .filter((filter) => !!filter) +} + +// First sort by sortOrder descending. The higher the sortOrder, the earlier it appears. +// If sortOrder is the same, sort by name as secondary criterion +export function sortFilters(filters: HotelFilter[]) { + return [...filters].sort((a, b) => { + const orderDiff = b.sortOrder - a.sortOrder + return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff + }) +} diff --git a/packages/trpc/lib/routers/hotels/query.ts b/packages/trpc/lib/routers/hotels/query.ts index d2d9bff11..6ef3fee2d 100644 --- a/packages/trpc/lib/routers/hotels/query.ts +++ b/packages/trpc/lib/routers/hotels/query.ts @@ -47,6 +47,7 @@ import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds" import { getLocationsByCountries } from "./services/getLocationsByCountries" import { getPackages } from "./services/getPackages" import { availability } from "./availability" +import { filtersRouter } from "./filters" import { locationsRouter } from "./locations" import type { HotelListingHotelData } from "../../types/hotel" @@ -331,6 +332,7 @@ export const hotelQueryRouter = router({ env.CACHE_TIME_HOTELS ) }), + filters: filtersRouter, locations: locationsRouter, map: router({ city: serviceProcedure diff --git a/packages/trpc/lib/types/hotel.ts b/packages/trpc/lib/types/hotel.ts index b8f97a5ae..3b179a0e7 100644 --- a/packages/trpc/lib/types/hotel.ts +++ b/packages/trpc/lib/types/hotel.ts @@ -1,4 +1,3 @@ -import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities" import type { z } from "zod" import type { @@ -87,20 +86,6 @@ export type RoomType = Pick export type RewardNight = z.output -export interface HotelFilter { - id: FacilityEnum - name: string - slug: string - filterType: string - sortOrder: number -} - -export interface CategorizedHotelFilters { - facilityFilters: HotelFilter[] - surroundingsFilters: HotelFilter[] - countryFilters: HotelFilter[] -} - export enum HotelSortOption { Recommended = "recommended", Distance = "distance", diff --git a/packages/trpc/lib/utils/getFiltersFromHotels.ts b/packages/trpc/lib/utils/getFiltersFromHotels.ts deleted file mode 100644 index 48d639b9c..000000000 --- a/packages/trpc/lib/utils/getFiltersFromHotels.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { logger } from "@scandic-hotels/common/logger" - -import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities" -import type { Lang } from "@scandic-hotels/common/constants/language" - -import type { DestinationFilters } from "../types/destinationsData" -import type { - CategorizedHotelFilters, - HotelFilter, - HotelListingHotelData, -} from "../types/hotel" - -const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [ - "Hotel surroundings", - "Hotel omgivelser", - "Hotelumgebung", - "Hotellia lähellä", - "Hotellomgivelser", - "Omgivningar", -] - -const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [ - "Hotel facilities", - "Hotellfaciliteter", - "Hotelfaciliteter", - "Hotel faciliteter", - "Hotel-Infos", - "Hotellin palvelut", -] - -function sortFilters(filters: HotelFilter[]): HotelFilter[] { - return [...filters].sort((a, b) => { - // First sort by sortOrder - const orderDiff = a.sortOrder - b.sortOrder - // If sortOrder is the same, sort by name as secondary criterion - return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff - }) -} - -// Merges hotel and SEO filters, removing duplicates (by id). -// In case of duplicates, the SEO filter takes precedence. -function mergeAndDeduplicate( - hotelFilters: HotelFilter[], - seoFilters: - | DestinationFilters["facilityFilters"] - | DestinationFilters["surroundingsFilters"] -): HotelFilter[] { - const map = new Map() - hotelFilters.forEach((filter) => map.set(filter.id, filter)) - seoFilters.forEach(({ filter }) => map.set(filter.id, filter)) - return Array.from(map.values()) -} - -export function mergeHotelFiltersAndSeoFilters( - hotelFilters: CategorizedHotelFilters, - seoFilters: DestinationFilters -): CategorizedHotelFilters { - if (!seoFilters) { - return hotelFilters - } - - return { - ...hotelFilters, - facilityFilters: mergeAndDeduplicate( - hotelFilters.facilityFilters, - seoFilters.facilityFilters - ), - surroundingsFilters: mergeAndDeduplicate( - hotelFilters.surroundingsFilters, - seoFilters.surroundingsFilters - ), - } -} - -export function getFiltersFromHotels( - hotels: HotelListingHotelData[], - lang: Lang -): CategorizedHotelFilters { - if (hotels.length === 0) { - return { facilityFilters: [], surroundingsFilters: [], countryFilters: [] } - } - - const uniqueCountries = new Set( - hotels.flatMap(({ hotel }) => hotel.countryCode) - ) - const countryFilters = [...uniqueCountries] - .map((countryCode) => { - const localizedCountry = getLocalizedCountryByCountryCode( - countryCode, - lang - ) - return localizedCountry - ? { - name: localizedCountry, - slug: countryCode.toLowerCase(), - filterType: "Country", - sortOrder: 0, - } - : null - }) - .filter((filter): filter is HotelFilter => !!filter) - const flattenedFacilityFilters = hotels.flatMap( - ({ hotel }) => hotel.detailedFacilities - ) - const uniqueFacilityFilterNames = [ - ...new Set(flattenedFacilityFilters.map((filter) => filter.name)), - ] - const facilityFilterList = uniqueFacilityFilterNames - .map((filterName) => { - const filter = flattenedFacilityFilters.find( - (filter) => filter.name === filterName - ) - return filter - ? { - id: filter.id, - name: filter.name, - slug: filter.slug, - filterType: filter.filter, - sortOrder: filter.sortOrder, - } - : null - }) - .filter((filter): filter is HotelFilter => !!filter) - - const facilityFilters = facilityFilterList.filter((filter) => - HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType) - ) - const surroundingsFilters = facilityFilterList.filter((filter) => - HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType) - ) - return { - facilityFilters: sortFilters(facilityFilters), - surroundingsFilters: sortFilters(surroundingsFilters), - countryFilters: sortFilters(countryFilters), - } -} - -function getLocalizedCountryByCountryCode( - countryCode: string, - lang: Lang -): string | null { - const country = new Intl.DisplayNames([lang], { type: "region" }) - const localizedCountry = country.of(countryCode) - if (!localizedCountry) { - logger.error(`Could not map ${countryCode} to localized country.`) - return null - } - - return localizedCountry -} diff --git a/packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts b/packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts new file mode 100644 index 000000000..0016b87ed --- /dev/null +++ b/packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts @@ -0,0 +1,43 @@ +// Merges hotel and SEO filters, removing duplicates (by id). + +import type { + HotelFilter, + HotelFilters, +} from "../routers/hotels/filters/output" +import type { + DestinationFilter, + DestinationFilters, +} from "../types/destinationsData" + +// In case of duplicates, the SEO filter takes precedence. +function mergeAndDeduplicate( + hotelFilters: HotelFilter[], + seoFilters: DestinationFilter[] +): HotelFilters["facilityFilters"] | HotelFilters["surroundingsFilters"] { + const map = new Map() + hotelFilters.forEach((filter) => map.set(filter.id, filter)) + seoFilters.forEach(({ filter }) => map.set(filter.id, filter)) + return Array.from(map.values()) +} + +export function mergeHotelFiltersAndSeoFilters( + hotelFilters: HotelFilters, + seoFilters: DestinationFilters +): HotelFilters { + if (!seoFilters) { + return hotelFilters + } + + return { + ...hotelFilters, + facilityFilters: mergeAndDeduplicate( + hotelFilters.facilityFilters, + seoFilters.facilityFilters + ), + surroundingsFilters: mergeAndDeduplicate( + hotelFilters.surroundingsFilters, + seoFilters.surroundingsFilters + ), + countryFilters: hotelFilters.countryFilters, + } +}