From af4f544b8afc96ac461b8979dbbcffa3e6e8dcca Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 24 Sep 2025 10:40:58 +0000 Subject: [PATCH] feat(BOOK-55): Listen to SEO filter slugs when navigating to such page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved-by: Chuma Mcphoy (We Ahead) Approved-by: Matilda Landström --- .../DestinationCityPage/index.tsx | 6 +- .../DestinationCountryPage/index.tsx | 5 +- .../DestinationDataProvider/index.tsx | 6 +- .../stores/destination-data/index.ts | 15 +++-- .../types/providers/destination-data.ts | 4 +- .../types/stores/destination-data.ts | 8 +-- .../DestinationCityPage/Metadata.graphql | 10 +++ .../destinationCityPage/output.ts | 4 +- .../destinationCountryPage/output.ts | 4 +- .../routers/contentstack/metadata/output.ts | 2 + .../routers/contentstack/metadata/utils.ts | 13 +++- .../schemas/destinationFilters.ts | 65 +++++++++++++------ .../contentstack/schemas/hotelFilter.ts | 18 ----- packages/trpc/lib/types/destinationsData.ts | 7 ++ .../trpc/lib/utils/getFiltersFromHotels.ts | 35 ++++++++++ 15 files changed, 141 insertions(+), 61 deletions(-) delete mode 100644 packages/trpc/lib/routers/contentstack/schemas/hotelFilter.ts diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx index 0d1336b0b..6fbc7d79f 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx @@ -64,7 +64,8 @@ export default async function DestinationCityPage({ } = destinationCityPage const allHotels = await getHotelsByCityIdentifier(cityIdentifier) - const allFilters = getFiltersFromHotels(allHotels, lang) + const hotelFilters = getFiltersFromHotels(allHotels, lang) + const sortItems: HotelSortItem[] = [ { label: intl.formatMessage({ @@ -92,7 +93,8 @@ export default async function DestinationCityPage({ }> diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx index 1431e6514..46f5e42f6 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx @@ -67,7 +67,7 @@ export default async function DestinationCountryPage({ getHotelsByCountry(destination_settings.country), getDestinationCityPagesByCountry(destination_settings.country), ]) - const allFilters = getFiltersFromHotels(allHotels, lang) + const hotelFilters = getFiltersFromHotels(allHotels, lang) const sortItems: HotelSortItem[] = [ { @@ -91,7 +91,8 @@ export default async function DestinationCountryPage({ diff --git a/apps/scandic-web/providers/DestinationDataProvider/index.tsx b/apps/scandic-web/providers/DestinationDataProvider/index.tsx index ac7449c42..92448414d 100644 --- a/apps/scandic-web/providers/DestinationDataProvider/index.tsx +++ b/apps/scandic-web/providers/DestinationDataProvider/index.tsx @@ -14,7 +14,8 @@ import type { DestinationDataProviderProps } from "@/types/providers/destination export default function DestinationDataProvider({ allCities = [], allHotels, - allFilters, + hotelFilters, + seoFilters, sortItems, pathname, children, @@ -26,7 +27,8 @@ export default function DestinationDataProvider({ storeRef.current = createDestinationDataStore({ allCities, allHotels, - allFilters, + hotelFilters, + seoFilters, pathname, sortItems, searchParams, diff --git a/apps/scandic-web/stores/destination-data/index.ts b/apps/scandic-web/stores/destination-data/index.ts index e7e00c3cc..f48824ca8 100644 --- a/apps/scandic-web/stores/destination-data/index.ts +++ b/apps/scandic-web/stores/destination-data/index.ts @@ -2,6 +2,7 @@ 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 { DestinationDataContext } from "@/contexts/DestinationData" @@ -28,15 +29,17 @@ import type { export function createDestinationDataStore({ allCities, allHotels, - allFilters, + hotelFilters, + seoFilters, pathname, sortItems, searchParams, }: InitialState) { const defaultSort = sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value - const flattenedFilters = Object.values(allFilters).flat() - const allFilterSlugs = flattenedFilters.map((filter) => filter.slug) + const allFilters = mergeHotelFiltersAndSeoFilters(hotelFilters, seoFilters) + const allFlattenedFilters = Object.values(allFilters).flat() + const allFilterSlugs = allFlattenedFilters.map((filter) => filter.slug) const activeFilters: HotelFilter[] = [] let filterFromUrl: HotelFilter | null = null @@ -47,7 +50,7 @@ export function createDestinationDataStore({ if (basePathnameWithoutFilters !== pathname) { const pathParts = pathname.split("/") filterFromUrl = - flattenedFilters.find( + allFlattenedFilters.find( (filter) => filter.slug === pathParts[pathParts.length - 1] ) ?? null if (filterFromUrl) { @@ -76,11 +79,11 @@ export function createDestinationDataStore({ sort && isValidSortOption(sort, state.sortItems) ? sort : state.defaultSort - const filters = flattenedFilters.filter((filter) => + const filters = allFlattenedFilters.filter((filter) => filterSlugs.includes(filter.slug) ) const filterFromUrl = - flattenedFilters.find( + allFlattenedFilters.find( (filter) => filter.slug === filterSlugFromUrl ) ?? null const filteredHotels = getFilteredHotels(state.allHotels, filters) diff --git a/apps/scandic-web/types/providers/destination-data.ts b/apps/scandic-web/types/providers/destination-data.ts index 0d46d2dd2..e3934133f 100644 --- a/apps/scandic-web/types/providers/destination-data.ts +++ b/apps/scandic-web/types/providers/destination-data.ts @@ -1,4 +1,5 @@ import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" +import type { SEOFilters } from "@scandic-hotels/trpc/types/destinationsData" import type { CategorizedHotelFilters, HotelListingHotelData, @@ -8,7 +9,8 @@ import type { export interface DestinationDataProviderProps extends React.PropsWithChildren { allHotels: HotelListingHotelData[] allCities?: DestinationCityListItem[] - allFilters: CategorizedHotelFilters + hotelFilters: CategorizedHotelFilters + seoFilters: SEOFilters | null filterFromUrl?: string sortItems: HotelSortItem[] pathname: string diff --git a/apps/scandic-web/types/stores/destination-data.ts b/apps/scandic-web/types/stores/destination-data.ts index 41d9b688f..012b77588 100644 --- a/apps/scandic-web/types/stores/destination-data.ts +++ b/apps/scandic-web/types/stores/destination-data.ts @@ -1,4 +1,5 @@ import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" +import type { SEOFilters } from "@scandic-hotels/trpc/types/destinationsData" import type { CategorizedHotelFilters, HotelFilter, @@ -42,10 +43,9 @@ export interface DestinationDataState { } export interface InitialState - extends Pick< - DestinationDataState, - "allHotels" | "allCities" | "sortItems" | "allFilters" - > { + extends Pick { pathname: string searchParams: ReadonlyURLSearchParams + seoFilters: SEOFilters | null + hotelFilters: CategorizedHotelFilters } diff --git a/packages/trpc/lib/graphql/Query/DestinationCityPage/Metadata.graphql b/packages/trpc/lib/graphql/Query/DestinationCityPage/Metadata.graphql index 6ebf7dd6d..02495e2b3 100644 --- a/packages/trpc/lib/graphql/Query/DestinationCityPage/Metadata.graphql +++ b/packages/trpc/lib/graphql/Query/DestinationCityPage/Metadata.graphql @@ -1,5 +1,6 @@ #import "../../Fragments/Metadata.graphql" #import "../../Fragments/System.graphql" +#import "../../Fragments/HotelFilter.graphql" query GetDestinationCityPageMetadata($locale: String!, $uid: String!) { destination_city_page(locale: $locale, uid: $uid) { @@ -22,6 +23,15 @@ query GetDestinationCityPageMetadata($locale: String!, $uid: String!) { images { image } + seo_filters { + filterConnection { + edges { + node { + ...HotelFilter + } + } + } + } system { ...System } diff --git a/packages/trpc/lib/routers/contentstack/destinationCityPage/output.ts b/packages/trpc/lib/routers/contentstack/destinationCityPage/output.ts index 225e9974a..24889484b 100644 --- a/packages/trpc/lib/routers/contentstack/destinationCityPage/output.ts +++ b/packages/trpc/lib/routers/contentstack/destinationCityPage/output.ts @@ -13,7 +13,7 @@ import { import { contentRefsSchema, contentSchema } from "../schemas/blocks/content" import { destinationFiltersRefsSchema, - destinationFiltersSchema, + transformedDestinationFiltersSchema, } from "../schemas/destinationFilters" import { mapLocationSchema } from "../schemas/mapLocation" import { @@ -163,7 +163,7 @@ export const destinationCityPageSchema = z.object({ }) .nullish(), blocks: discriminatedUnionArray(blocksSchema.options).nullable(), - seo_filters: destinationFiltersSchema, + seo_filters: transformedDestinationFiltersSchema, system: systemSchema.merge( z.object({ created_at: z.string(), diff --git a/packages/trpc/lib/routers/contentstack/destinationCountryPage/output.ts b/packages/trpc/lib/routers/contentstack/destinationCountryPage/output.ts index 2d349c18d..2efa28183 100644 --- a/packages/trpc/lib/routers/contentstack/destinationCountryPage/output.ts +++ b/packages/trpc/lib/routers/contentstack/destinationCountryPage/output.ts @@ -13,7 +13,7 @@ import { import { contentRefsSchema, contentSchema } from "../schemas/blocks/content" import { destinationFiltersRefsSchema, - destinationFiltersSchema, + transformedDestinationFiltersSchema, } from "../schemas/destinationFilters" import { mapLocationSchema } from "../schemas/mapLocation" import { @@ -92,7 +92,7 @@ export const destinationCountryPageSchema = z.object({ }) .nullish(), blocks: discriminatedUnionArray(blocksSchema.options).nullable(), - seo_filters: destinationFiltersSchema, + seo_filters: transformedDestinationFiltersSchema, system: systemSchema.merge( z.object({ created_at: z.string(), diff --git a/packages/trpc/lib/routers/contentstack/metadata/output.ts b/packages/trpc/lib/routers/contentstack/metadata/output.ts index 15fe1c654..3d390aded 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/output.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/output.ts @@ -12,6 +12,7 @@ import { Country } from "../../../types/country" import { RTETypeEnum } from "../../../types/RTEenums" import { additionalDataAttributesSchema } from "../../hotels/schemas/hotel/include/additionalData" import { imageSchema } from "../../hotels/schemas/image" +import { destinationFiltersSchema } from "../schemas/destinationFilters" import { systemSchema } from "../schemas/system" import type { Lang } from "@scandic-hotels/common/constants/language" @@ -166,6 +167,7 @@ export const rawMetadataSchema = z.object({ cities: z.array(z.string()).nullish(), }) .nullish(), + seo_filters: destinationFiltersSchema, system: systemSchema, }) diff --git a/packages/trpc/lib/routers/contentstack/metadata/utils.ts b/packages/trpc/lib/routers/contentstack/metadata/utils.ts index f77e9d204..8ddb46f1e 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/utils.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/utils.ts @@ -1,6 +1,9 @@ import { ApiCountry } from "../../../types/country" import { HotelSortOption } from "../../../types/hotel" -import { getFiltersFromHotels } from "../../../utils/getFiltersFromHotels" +import { + getFiltersFromHotels, + mergeHotelFiltersAndSeoFilters, +} from "../../../utils/getFiltersFromHotels" import { getSortedCities } from "../../../utils/getSortedCities" import { getCityByCityIdentifier, @@ -9,6 +12,7 @@ import { getHotelsByHotelIds, } from "../../hotels/utils" import { getCityPages } from "../destinationCountryPage/utils" +import { transformDestinationFiltersResponse } from "../schemas/destinationFilters" import type { Lang } from "@scandic-hotels/common/constants/language" @@ -24,6 +28,7 @@ export async function getCityData( lang: Lang ) { const destinationSettings = data.destination_settings + const seoFilters = transformDestinationFiltersResponse(data.seo_filters) const filter = input.filterFromUrl if (destinationSettings) { @@ -61,7 +66,11 @@ export async function getCityData( let filterType if (filter) { - const allFilters = getFiltersFromHotels(hotels, lang) + const hotelFilters = getFiltersFromHotels(hotels, 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 b99e4c9f7..566a0f9f2 100644 --- a/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts +++ b/packages/trpc/lib/routers/contentstack/schemas/destinationFilters.ts @@ -1,8 +1,8 @@ import { z } from "zod" +import { FacilityEnum } from "@scandic-hotels/common/constants/facilities" import { isDefined } from "@scandic-hotels/common/utils/isDefined" -import { hotelFilterSchema } from "./hotelFilter" import { systemSchema } from "./system" export const destinationFiltersSchema = z @@ -11,32 +11,25 @@ export const destinationFiltersSchema = z filterConnection: z.object({ edges: z.array( z.object({ - node: hotelFilterSchema, + node: z.object({ + title: z.string(), + facility_id: z + .nativeEnum(FacilityEnum) + .catch(FacilityEnum.UNKNOWN), + category: z.string(), + slug: z.string(), + }), }) ), }), }) ) .nullish() - .transform((data) => { - const filters = data - ?.map(({ filterConnection }) => filterConnection.edges[0]?.node) - .filter(isDefined) - if (!data || !filters?.length) { - return null - } - - const facilityFilters = filters.filter((f) => f.filterType === "facility") - const surroundingsFilters = filters.filter( - (f) => f.filterType === "surroundings" - ) - - return { - facilityFilters, - surroundingsFilters, - } - }) +export const transformedDestinationFiltersSchema = + destinationFiltersSchema.transform((data) => + transformDestinationFiltersResponse(data) + ) export const destinationFiltersRefsSchema = z .array( @@ -53,3 +46,35 @@ export const destinationFiltersRefsSchema = z }) ) .nullish() + +export function transformDestinationFiltersResponse( + data: typeof destinationFiltersSchema._type +) { + const filters = data + ?.map(({ filterConnection }) => filterConnection.edges[0]?.node) + .filter(isDefined) + + if (!data || !filters?.length) { + return null + } + + const transformedFilters = filters.map((filter) => ({ + id: filter.facility_id, + name: filter.title, + filterType: filter.category, + slug: filter.slug, + sortOrder: 0, + })) + + const facilityFilters = transformedFilters.filter( + (f) => f.filterType === "facility" + ) + const surroundingsFilters = transformedFilters.filter( + (f) => f.filterType === "surroundings" + ) + + return { + facilityFilters, + surroundingsFilters, + } +} diff --git a/packages/trpc/lib/routers/contentstack/schemas/hotelFilter.ts b/packages/trpc/lib/routers/contentstack/schemas/hotelFilter.ts deleted file mode 100644 index b2ddc5f36..000000000 --- a/packages/trpc/lib/routers/contentstack/schemas/hotelFilter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod" - -import { FacilityEnum } from "@scandic-hotels/common/constants/facilities" - -export const hotelFilterSchema = z - .object({ - title: z.string(), - facility_id: z.nativeEnum(FacilityEnum).catch(FacilityEnum.UNKNOWN), - category: z.string(), - slug: z.string(), - }) - .transform((data) => ({ - id: data.facility_id, - name: data.title, - filterType: data.category, - slug: data.slug, - sortOrder: 0, - })) diff --git a/packages/trpc/lib/types/destinationsData.ts b/packages/trpc/lib/types/destinationsData.ts index a28043f80..9a35f6aa5 100644 --- a/packages/trpc/lib/types/destinationsData.ts +++ b/packages/trpc/lib/types/destinationsData.ts @@ -1,3 +1,5 @@ +import type { HotelFilter } from "./hotel" + export type City = { id: string name: string @@ -14,3 +16,8 @@ export type DestinationCountry = { } export type DestinationsData = DestinationCountry[] + +export type SEOFilters = { + facilityFilters: HotelFilter[] + surroundingsFilters: HotelFilter[] +} diff --git a/packages/trpc/lib/utils/getFiltersFromHotels.ts b/packages/trpc/lib/utils/getFiltersFromHotels.ts index a883e8eea..f5ff0c5ad 100644 --- a/packages/trpc/lib/utils/getFiltersFromHotels.ts +++ b/packages/trpc/lib/utils/getFiltersFromHotels.ts @@ -1,5 +1,7 @@ +import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities" import type { Lang } from "@scandic-hotels/common/constants/language" +import type { SEOFilters } from "../types/destinationsData" import type { CategorizedHotelFilters, HotelFilter, @@ -33,6 +35,39 @@ function sortFilters(filters: HotelFilter[]): HotelFilter[] { }) } +// Merges hotel and SEO filters, removing duplicates (by id). +// In case of duplicates, the SEO filter takes precedence. +function mergeAndDeduplicate( + hotelFilters: HotelFilter[], + seoFilters: HotelFilter[] +): 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: SEOFilters | null +): 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