diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/Client.tsx b/apps/scandic-web/components/Blocks/CampaignHotelListing/Client.tsx index d498423f2..eb2908972 100644 --- a/apps/scandic-web/components/Blocks/CampaignHotelListing/Client.tsx +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/Client.tsx @@ -12,57 +12,64 @@ import { } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" +import { useHotelListingDataStore } from "@/stores/hotel-listing-data" + +import CampaignHotelListingSkeleton from "@/components/Blocks/CampaignHotelListing/CampaignHotelListingSkeleton" +import HotelFilterAndSort from "@/components/HotelFilterAndSort" + import HotelListingItem from "./HotelListingItem" import styles from "./campaignHotelListing.module.css" -import type { HotelDataWithUrl } from "@scandic-hotels/trpc/types/hotel" - interface CampaignHotelListingClientProps { heading: string preamble?: string | null - hotels: HotelDataWithUrl[] - visibleCountMobile?: 3 | 6 - visibleCountDesktop?: 3 | 6 + visibleCountMobile: 3 | 6 + visibleCountDesktop: 3 | 6 + isMainBlock: boolean } export default function CampaignHotelListingClient({ heading, preamble, - hotels, - visibleCountMobile = 3, - visibleCountDesktop = 6, + visibleCountMobile, + visibleCountDesktop, + isMainBlock, }: CampaignHotelListingClientProps) { const intl = useIntl() const isMobile = useMediaQuery("(max-width: 767px)") const scrollRef = useRef(null) + const { activeHotels, isLoading } = useHotelListingDataStore((state) => ({ + activeHotels: state.activeHotels, + isLoading: state.isLoading, + })) - const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of hotels to show + const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of activeHotels to show const thresholdCount = initialCount + 3 // This is the threshold at which we start showing the "Show More" button const showAllThreshold = initialCount * 3 // This is the threshold at which we show the "Show All" button - const incrementCount = initialCount // Number of hotels to increment when the button is clicked + const incrementCount = initialCount // Number of activeHotels to increment when the button is clicked const [visibleCount, setVisibleCount] = useState(() => - // Set initial visible count based on the number of hotels and the threshold - hotels.length <= thresholdCount ? hotels.length : initialCount + // Set initial visible count based on the number of activeHotels and the threshold + activeHotels.length <= thresholdCount ? activeHotels.length : initialCount ) - // Only show the show more/less button if the length of hotels exceeds the threshold count - const showButton = hotels.length > thresholdCount + // Only show the show more/less button if the length of activeHotels exceeds the threshold count + const showButton = activeHotels.length > thresholdCount - // Determine if we are at the stage where the user can click to show all hotels + // Determine if we are at the stage where the user can click to show all activeHotels const canShowAll = - hotels.length > visibleCount && + activeHotels.length > visibleCount && (visibleCount + incrementCount > showAllThreshold || - visibleCount + incrementCount >= hotels.length) + visibleCount + incrementCount >= activeHotels.length) function handleButtonClick() { - if (visibleCount < hotels.length) { + if (visibleCount < activeHotels.length) { if (canShowAll) { - setVisibleCount(hotels.length) + setVisibleCount(activeHotels.length) } else { setVisibleCount((prev) => - Math.min(prev + incrementCount, hotels.length) + Math.min(prev + incrementCount, activeHotels.length) ) } } else { @@ -78,7 +85,7 @@ export default function CampaignHotelListingClient({ }) let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down" - if (visibleCount === hotels.length) { + if (visibleCount === activeHotels.length) { buttonText = intl.formatMessage({ defaultMessage: "Show less", }) @@ -89,20 +96,30 @@ export default function CampaignHotelListingClient({ }) } + if (isLoading) { + return + } + return ( -
+
- -

{heading}

+ +

{heading}

+ {isMainBlock ? : null} {preamble ? ( -

{preamble}

+

{preamble}

) : null}
    - {hotels.map(({ hotel, url }, index) => ( + {activeHotels.map(({ hotel, url }, index) => (
  • @@ -57,7 +57,7 @@ export default function HotelListingItem({
    @@ -111,19 +111,21 @@ export default function HotelListingItem({
-
- - {intl.formatMessage({ - defaultMessage: "See hotel details", - })} - -
+ {url ? ( +
+ + {intl.formatMessage({ + defaultMessage: "See hotel details", + })} + +
+ ) : null} ) } diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/Content.tsx b/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/Content.tsx new file mode 100644 index 000000000..7cba6b81c --- /dev/null +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/Content.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useParams } from "next/navigation" +import { useEffect } from "react" + +import { useDestinationDataStore } from "@/stores/destination-data" + +export default function DestinationDataProviderContent({ + children, +}: React.PropsWithChildren) { + const params = useParams() + const { basePath, updateActiveFiltersAndSort } = useDestinationDataStore( + (state) => ({ + basePath: state.basePathnameWithoutFilters, + updateActiveFiltersAndSort: state.actions.updateActiveFiltersAndSort, + }) + ) + + useEffect(() => { + const currentUrl = new URL(window.location.href) + const searchParams = currentUrl.searchParams + const currentPathname = currentUrl.pathname + const currentHash = currentUrl.hash + const sort = searchParams.get("sort") + const filters = [] + const pathParts = currentPathname.split("/") + const lastPathPart = pathParts[pathParts.length - 1] + + if (basePath !== currentPathname) { + filters.push(lastPathPart) + } + if (currentHash) { + const hashValue = currentHash.substring(1) + filters.push(...hashValue.split("&")) + } + + updateActiveFiltersAndSort(filters, sort) + }, [params, updateActiveFiltersAndSort, basePath]) + + return <>{children} +} diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/index.tsx b/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/index.tsx new file mode 100644 index 000000000..583833c44 --- /dev/null +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/Provider/index.tsx @@ -0,0 +1,45 @@ +"use client" +import { useSearchParams } from "next/navigation" +import { useRef } from "react" + +import { createDestinationDataStore } from "@/stores/destination-data" + +import { DestinationDataContext } from "@/contexts/DestinationData" + +import DestinationDataProviderContent from "./Content" + +import type { DestinationDataStore } from "@/types/contexts/destination-data" +import type { DestinationDataProviderProps } from "@/types/providers/destination-data" + +export default function DestinationDataProvider({ + allCities = [], + allHotels, + allFilters, + filterFromUrl, + sortItems, + pathname, + children, +}: DestinationDataProviderProps) { + const storeRef = useRef(undefined) + const searchParams = useSearchParams() + + if (!storeRef.current) { + storeRef.current = createDestinationDataStore({ + allCities, + allHotels, + allFilters, + filterFromUrl, + pathname, + sortItems, + searchParams, + }) + } + + return ( + + + {children} + + + ) +} diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/campaignHotelListing.module.css b/apps/scandic-web/components/Blocks/CampaignHotelListing/campaignHotelListing.module.css index 6faa7cd3a..84ad8a4a7 100644 --- a/apps/scandic-web/components/Blocks/CampaignHotelListing/campaignHotelListing.module.css +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/campaignHotelListing.module.css @@ -6,13 +6,22 @@ display: grid; gap: var(--Space-x3); scroll-margin-top: var(--scroll-margin-top); + + &.isMainBlock .heading { + color: var(--Text-Heading); + } } .header { display: grid; + grid-template-columns: 1fr max-content; gap: var(--Space-x15); } +.preamble { + grid-column: span 2; +} + .list { list-style: none; display: grid; @@ -28,6 +37,10 @@ --scroll-margin-top: calc( var(--booking-widget-tablet-height) + var(--Spacing-x2) ); + + &.isMainBlock { + gap: var(--Space-x5); + } } .list { row-gap: var(--Space-x5); diff --git a/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx b/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx index 3eeaa1b40..8f0277077 100644 --- a/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx +++ b/apps/scandic-web/components/Blocks/CampaignHotelListing/index.tsx @@ -1,5 +1,18 @@ +import { Suspense } from "react" + +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 { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" +import HotelListingDataProvider from "@/providers/HotelListingDataProvider" + +import CampaignHotelListingSkeleton from "./CampaignHotelListingSkeleton" import CampaignHotelListingClient from "./Client" interface CampaignHotelListingProps { @@ -8,28 +21,56 @@ interface CampaignHotelListingProps { hotelIds: string[] visibleCountMobile?: 3 | 6 visibleCountDesktop?: 3 | 6 + isMainBlock?: boolean } export default async function CampaignHotelListing({ heading, preamble, hotelIds, - visibleCountMobile, - visibleCountDesktop, + visibleCountMobile = 3, + visibleCountDesktop = 6, + 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 sortItems: HotelSortItem[] = [ + { + label: intl.formatMessage({ + defaultMessage: "Name", + }), + value: HotelSortOption.Name, + }, + { + label: intl.formatMessage({ + defaultMessage: "TripAdvisor rating", + }), + value: HotelSortOption.TripAdvisorRating, + }, + ] + return ( - + }> + + + + ) } diff --git a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx b/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx index 9f2f76466..bf75e91a2 100644 --- a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx +++ b/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx @@ -7,32 +7,42 @@ import Image from "@/components/Image" import { getIntl } from "@/i18n" import { getSingleDecimal } from "@/utils/numberFormatting" -import { getTypeSpecificInformation } from "./utils" - import styles from "./hotelListingItem.module.css" import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem" export default async function HotelListingItem({ - hotel, - additionalData, + hotelData, contentType = "hotel", - url, }: HotelListingItemProps) { const intl = await getIntl() - const { description, image, cta } = getTypeSpecificInformation( - intl, - contentType, - hotel.hotelContent, - additionalData, - url - ) + + const { galleryImages, description, id, name, hotelType, location, address } = + hotelData.hotel + const image = galleryImages[0] + + const cta = + contentType === "meeting" && hotelData.meetingUrl + ? { + url: hotelData.meetingUrl, + openInNewTab: true, + text: intl.formatMessage({ + defaultMessage: "Book a meeting", + }), + } + : { + url: hotelData.url, + openInNewTab: false, + text: intl.formatMessage({ + defaultMessage: "See hotel details", + }), + } return (
{image.alt}
- + -

{hotel.name}

+

{name}

- {hotel.address.streetAddress} + {address.streetAddress} {intl.formatMessage( @@ -54,9 +64,7 @@ export default async function HotelListingItem({ defaultMessage: "{number} km to city center", }, { - number: getSingleDecimal( - hotel.location.distanceToCentre / 1000 - ), + number: getSingleDecimal(location.distanceToCentre / 1000), } )} @@ -77,7 +85,7 @@ export default async function HotelListingItem({ variant="Primary" size="Small" href={cta.url} - target={cta.openInNewTab ? "_blank" : "_self"} + target={cta.openInNewTab ? "_blank" : undefined} className={styles.button} > {cta.text} diff --git a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/utils.ts b/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/utils.ts deleted file mode 100644 index abcd797e6..000000000 --- a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/utils.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { AdditionalData, Hotel } from "@scandic-hotels/trpc/types/hotel" -import type { IntlShape } from "react-intl" - -import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks" - -export function getTypeSpecificInformation( - intl: IntlShape, - contentType: HotelListing["contentType"], - hotelContent: Hotel["hotelContent"], - additionalData: AdditionalData, - url: string | null -) { - const { images, texts } = hotelContent - const { descriptions, meetingDescription } = texts - const { conferencesAndMeetings, restaurantsOverviewPage, restaurantImages } = - additionalData - const data = { - description: descriptions?.short, - image: { - src: images.imageSizes.small, - alt: images.metaData.altText, - }, - cta: { - text: intl.formatMessage({ - defaultMessage: "See hotel details", - }), - url, - openInNewTab: false, - }, - } - switch (contentType) { - case "meeting": - const meetingImage = conferencesAndMeetings?.heroImages[0] - const meetingUrl = additionalData.meetingRooms.meetingOnlineLink - if (meetingDescription?.short) { - data.description = meetingDescription.short - } - if (meetingImage) { - data.image = { - src: meetingImage.imageSizes.small, - alt: meetingImage.metaData.altText, - } - } - if (meetingUrl) { - data.cta = { - text: intl.formatMessage({ - defaultMessage: "Book a meeting", - }), - url: meetingUrl, - openInNewTab: true, - } - } - return data - case "restaurant": - const restaurantImage = restaurantImages?.heroImages[0] - if (restaurantsOverviewPage.restaurantsContentDescriptionShort) { - data.description = - restaurantsOverviewPage.restaurantsContentDescriptionShort - } - if (restaurantImage) { - data.image = { - src: restaurantImage.imageSizes.small, - alt: restaurantImage.metaData.altText, - } - } - return data - case "hotel": - default: - return data - } -} diff --git a/apps/scandic-web/components/Blocks/HotelListing/index.tsx b/apps/scandic-web/components/Blocks/HotelListing/index.tsx index 1615a2dd2..89a39fe4c 100644 --- a/apps/scandic-web/components/Blocks/HotelListing/index.tsx +++ b/apps/scandic-web/components/Blocks/HotelListing/index.tsx @@ -30,13 +30,11 @@ export default async function HotelListing({

{heading}

- {hotels.map(({ url, hotel, additionalData }) => ( + {hotels.map((hotelData) => ( ))} diff --git a/apps/scandic-web/components/ContentType/CampaignPage/Blocks/index.tsx b/apps/scandic-web/components/ContentType/CampaignPage/Blocks/index.tsx index dc7ddbdbc..06abba06e 100644 --- a/apps/scandic-web/components/ContentType/CampaignPage/Blocks/index.tsx +++ b/apps/scandic-web/components/ContentType/CampaignPage/Blocks/index.tsx @@ -38,6 +38,7 @@ export default function Blocks({ blocks }: BlocksProps) { ) diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/Content.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/Content.tsx index 34af25f9c..6cab26005 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/Content.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/Content.tsx @@ -12,11 +12,11 @@ import HotelListItem from "../HotelListItem" import styles from "./hotelList.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" interface HotelListContentProps { hotelsCount: number - visibleHotels: DestinationPagesHotelData[] + visibleHotels: HotelListingHotelData[] } export default function HotelListContent({ diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx index 671769693..e989734f7 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx @@ -17,15 +17,15 @@ import { getVisibleHotels } from "./utils" import styles from "./hotelList.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" export default function HotelList() { const intl = useIntl() const map = useMap() const coreLib = useMapsLibrary("core") - const [visibleHotels, setVisibleHotels] = useState< - DestinationPagesHotelData[] - >([]) + const [visibleHotels, setVisibleHotels] = useState( + [] + ) const { activeHotels, isLoading } = useDestinationDataStore((state) => ({ activeHotels: state.activeHotels, isLoading: state.isLoading, diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts index d4554a886..3c7765308 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts @@ -1,7 +1,7 @@ -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" export function getVisibleHotels( - hotels: DestinationPagesHotelData[], + hotels: HotelListingHotelData[], map: google.maps.Map | null ) { const bounds = map?.getBounds() diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx index f945fb511..7e70cdede 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx @@ -19,11 +19,15 @@ import { getSingleDecimal } from "@/utils/numberFormatting" import styles from "./hotelListItem.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" -export default function HotelListItem(data: DestinationPagesHotelData) { +interface HotelListItemProps { + hotel: HotelListingHotelData["hotel"] + url: string | null +} + +export default function HotelListItem({ hotel, url }: HotelListItemProps) { const intl = useIntl() - const { hotel, url } = data const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const amenities = hotel.detailedFacilities.slice(0, 5) const address = `${hotel.address.streetAddress}, ${hotel.address.city}` diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx index 6cc45c8d0..2bebf8e5d 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCityPage/index.tsx @@ -1,8 +1,11 @@ import { notFound } from "next/navigation" import { Suspense } from "react" -import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" -import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers" +import { + type HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" +import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels" import { env } from "@/env/server" import { @@ -13,6 +16,7 @@ import { import Breadcrumbs from "@/components/Breadcrumbs" 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" @@ -30,8 +34,6 @@ import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton" import styles from "./destinationCityPage.module.css" -import type { SortItem } from "@/types/components/destinationFilterAndSort" - interface DestinationCityPageProps { isMapView: boolean filterFromUrl?: string @@ -42,6 +44,7 @@ export default async function DestinationCityPage({ filterFromUrl, }: DestinationCityPageProps) { const intl = await getIntl() + const lang = await getLang() const pathname = await getPathname() const pageData = await getDestinationCityPage() @@ -62,26 +65,26 @@ export default async function DestinationCityPage({ } = destinationCityPage const allHotels = await getHotelsByCityIdentifier(cityIdentifier) - const allFilters = getFiltersFromHotels(allHotels) - const sortItems: SortItem[] = [ + const allFilters = getFiltersFromHotels(allHotels, lang) + const sortItems: HotelSortItem[] = [ { label: intl.formatMessage({ defaultMessage: "Distance to city center", }), - value: SortOption.Distance, + value: HotelSortOption.Distance, isDefault: true, }, { label: intl.formatMessage({ defaultMessage: "Name", }), - value: SortOption.Name, + value: HotelSortOption.Name, }, { label: intl.formatMessage({ defaultMessage: "TripAdvisor rating", }), - value: SortOption.TripAdvisorRating, + value: HotelSortOption.TripAdvisorRating, }, ] diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx index 4310a42b7..d8018c13b 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx @@ -1,8 +1,11 @@ import { notFound } from "next/navigation" import { Suspense } from "react" -import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" -import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers" +import { + type HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" +import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels" import { env } from "@/env/server" import { @@ -14,6 +17,7 @@ import { import Breadcrumbs from "@/components/Breadcrumbs" 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" @@ -30,8 +34,6 @@ import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton" import styles from "./destinationCountryPage.module.css" -import type { SortItem } from "@/types/components/destinationFilterAndSort" - interface DestinationCountryPageProps { isMapView: boolean filterFromUrl?: string @@ -42,6 +44,7 @@ export default async function DestinationCountryPage({ filterFromUrl, }: DestinationCountryPageProps) { const intl = await getIntl() + const lang = await getLang() const pathname = await getPathname() const pageData = await getDestinationCountryPage() @@ -65,21 +68,21 @@ export default async function DestinationCountryPage({ getHotelsByCountry(destination_settings.country), getDestinationCityPagesByCountry(destination_settings.country), ]) - const allFilters = getFiltersFromHotels(allHotels) + const allFilters = getFiltersFromHotels(allHotels, lang) - const sortItems: SortItem[] = [ + const sortItems: HotelSortItem[] = [ { label: intl.formatMessage({ defaultMessage: "Recommended", }), - value: SortOption.Recommended, + value: HotelSortOption.Recommended, isDefault: true, }, { label: intl.formatMessage({ defaultMessage: "Name", }), - value: SortOption.Name, + value: HotelSortOption.Name, }, ] diff --git a/apps/scandic-web/components/ContentType/DestinationPage/HotelCardCarousel/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/HotelCardCarousel/index.tsx index 1d231003c..3a99a9f84 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/HotelCardCarousel/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/HotelCardCarousel/index.tsx @@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard" import styles from "./hotelCardCarousel.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" interface MapCardCarouselProps { - visibleHotels: DestinationPagesHotelData[] + visibleHotels: HotelListingHotelData[] } export default function HotelCardCarousel({ visibleHotels, @@ -52,7 +52,7 @@ export default function HotelCardCarousel({ tripadvisorRating={hotel.tripadvisor} hotelName={hotel.name} url={url} - image={getImage({ hotel, url })} + image={getImage({ hotel })} amenities={hotel.detailedFacilities.slice(0, 3)} /> @@ -62,11 +62,11 @@ export default function HotelCardCarousel({ ) } -function getImage(hotel: DestinationPagesHotelData) { +function getImage({ hotel }: Pick) { return { - src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium, + src: hotel.galleryImages?.[0]?.imageSizes.large, alt: - hotel.hotel.galleryImages?.[0]?.metaData.altText || - hotel.hotel.galleryImages?.[0]?.metaData.altText_En, + hotel.galleryImages?.[0]?.metaData.altText || + hotel.galleryImages?.[0]?.metaData.altText_En, } } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/HotelListing/HotelListingItem/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/HotelListing/HotelListingItem/index.tsx index c3e6c4644..ae26eb2c4 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/HotelListing/HotelListingItem/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/HotelListing/HotelListingItem/index.tsx @@ -21,12 +21,19 @@ import { getSingleDecimal } from "@/utils/numberFormatting" import styles from "./hotelListingItem.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" -export default function HotelListingItem(data: DestinationPagesHotelData) { +interface HotelListingItemProps { + hotel: HotelListingHotelData["hotel"] + url: string | null +} + +export default function HotelListingItem({ + hotel, + url, +}: HotelListingItemProps) { const intl = useIntl() const params = useParams() - const { hotel, url } = data const { setActiveMarker } = useDestinationPageHotelsMapStore() const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const amenities = hotel.detailedFacilities.slice(0, 5) @@ -103,9 +110,9 @@ export default function HotelListingItem(data: DestinationPagesHotelData) {
- {hotel.hotelDescription ? ( + {hotel.description ? ( -

{hotel.hotelDescription}

+

{hotel.description}

) : null} diff --git a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/DialogImage/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/DialogImage/index.tsx index 06eeabe33..dccbc5bd7 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/DialogImage/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/DialogImage/index.tsx @@ -9,7 +9,7 @@ import styles from "./dialogImage.module.css" interface DialogImageProps { image?: string altText?: string - rating?: number + rating?: number | null imageError: boolean setImageError: (error: boolean) => void } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx index 5c3c730e1..58f9c96c6 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/HotelMapCard/index.tsx @@ -22,10 +22,10 @@ import type { GalleryImage } from "@/types/components/imageGallery" interface HotelMapCardProps { amenities: Amenities - tripadvisorRating: number | undefined + tripadvisorRating: number | null hotelName: string image: GalleryImage | null - url: string + url: string | null className?: string } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/Map/index.tsx index 7309f9085..8a7f8342f 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/index.tsx @@ -29,12 +29,12 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils" import styles from "./map.module.css" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" import type { MapLocation } from "@/types/components/mapLocation" interface MapProps { - hotels: DestinationPagesHotelData[] + hotels: HotelListingHotelData[] mapId: string apiKey: string pageType: "city" | "country" diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/utils.ts b/apps/scandic-web/components/ContentType/DestinationPage/Map/utils.ts index db20ccabf..4f2b6fa13 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/utils.ts +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/utils.ts @@ -1,4 +1,4 @@ -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" import type { DestinationMarker, @@ -29,7 +29,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) { return geoJson } -export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) { +export function getHotelMapMarkers(hotels: HotelListingHotelData[]) { const markers = hotels .map(({ hotel, url }) => ({ id: hotel.id, @@ -41,10 +41,10 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) { lng: hotel.location.longitude, } : null, - url: url, + url, tripadvisor: hotel.tripadvisor, amenities: hotel.detailedFacilities.slice(0, 3), - image: getImage({ hotel, url }), + image: getImage({ hotel }), })) .filter((item): item is DestinationMarker => !!item.coordinates) @@ -52,11 +52,11 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) { return markers } -function getImage(hotel: DestinationPagesHotelData) { +function getImage({ hotel }: Pick) { return { - src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium, + src: hotel.galleryImages?.[0]?.imageSizes.large, alt: - hotel.hotel.galleryImages?.[0]?.metaData.altText || - hotel.hotel.galleryImages?.[0]?.metaData.altText_En, + hotel.galleryImages?.[0]?.metaData.altText || + hotel.galleryImages?.[0]?.metaData.altText_En, } } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/utils.ts b/apps/scandic-web/components/ContentType/DestinationPage/utils.ts index 1b544e3dd..ffb1ba983 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/utils.ts +++ b/apps/scandic-web/components/ContentType/DestinationPage/utils.ts @@ -1,10 +1,10 @@ -import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort" +import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel" import type { IntlShape } from "react-intl" export function getHeadingText( intl: IntlShape, location: string, - allFilters: CategorizedFilters, + allFilters: CategorizedHotelFilters, filter?: string ) { if (filter) { diff --git a/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx b/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx index 31be93faf..768bb7b30 100644 --- a/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx +++ b/apps/scandic-web/components/DestinationFilterAndSort/Filter/index.tsx @@ -11,10 +11,10 @@ import Checkbox from "./Checkbox" import styles from "./filter.module.css" -import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort" +import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel" interface FilterProps { - filters: CategorizedFilters + filters: CategorizedHotelFilters } export default function Filter({ filters }: FilterProps) { diff --git a/apps/scandic-web/components/DestinationFilterAndSort/Sort/index.tsx b/apps/scandic-web/components/DestinationFilterAndSort/Sort/index.tsx index 6cff44c7b..790ca0c05 100644 --- a/apps/scandic-web/components/DestinationFilterAndSort/Sort/index.tsx +++ b/apps/scandic-web/components/DestinationFilterAndSort/Sort/index.tsx @@ -6,12 +6,13 @@ import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect" import { useDestinationDataStore } from "@/stores/destination-data" -import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" - -import type { SortItem } from "@/types/components/destinationFilterAndSort" +import type { + HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" interface SortProps { - sortItems: SortItem[] + sortItems: HotelSortItem[] } export default function Sort({ sortItems }: SortProps) { @@ -33,7 +34,7 @@ export default function Sort({ sortItems }: SortProps) { })} name="sort" showRadioButton - onSelect={(sort) => setPendingSort(sort as SortOption)} + onSelect={(sort) => setPendingSort(sort as HotelSortOption)} /> ) } diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css new file mode 100644 index 000000000..1ee40afdd --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css @@ -0,0 +1,41 @@ +.checkboxWrapper { + display: flex; + align-items: center; + gap: var(--Space-x15); + padding: var(--Space-x1) var(--Space-x15); + cursor: pointer; + border-radius: var(--Corner-radius-md); + transition: background-color 0.3s; +} + +.checkboxWrapper:hover { + background-color: var(--UI-Input-Controls-Surface-Hover); +} + +.checkbox { + width: 24px; + height: 24px; + min-width: 24px; + border: 1px solid var(--UI-Input-Controls-Border-Normal); + border-radius: var(--Corner-radius-sm); + transition: all 0.3s; + 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; + } +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx new file mode 100644 index 000000000..74ad14a40 --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/Checkbox/index.tsx @@ -0,0 +1,41 @@ +"use client" + +import { Checkbox as AriaCheckbox } from "react-aria-components" + +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import styles from "./checkbox.module.css" + +interface CheckboxProps { + name: string + value: string + isSelected: boolean + onChange: (filterId: string) => void +} + +export default function Checkbox({ + isSelected, + name, + value, + onChange, +}: CheckboxProps) { + return ( + onChange(value)} + > + {({ isSelected }) => ( + <> + + {isSelected && } + + + {name} + + + )} + + ) +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/filter.module.css b/apps/scandic-web/components/HotelFilterAndSort/Filter/filter.module.css new file mode 100644 index 000000000..aea41ec59 --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/filter.module.css @@ -0,0 +1,41 @@ +.container { + display: grid; + gap: var(--Space-x2); +} + +.form { + display: grid; + gap: var(--Space-x4); +} + +.heading { + color: var(--Text-Heading); +} + +.fieldset { + border: none; + padding: 0; + display: grid; +} + +.fieldset:first-of-type { + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +.list { + display: grid; + grid-template-columns: repeat(3, 1fr); + list-style: none; + gap: var(--Space-x1) var(--Space-x2); + margin: var(--Space-x3) 0; +} + +@media screen and (max-width: 767px) { + .list { + grid-template-columns: 1fr; + } + + .list label { + padding-left: 0; + } +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx new file mode 100644 index 000000000..814579781 --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useIntl } from "react-intl" + +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { useHotelListingDataStore } from "@/stores/hotel-listing-data" + +import Checkbox from "./Checkbox" + +import styles from "./filter.module.css" + +import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel" + +interface FilterProps { + filters: CategorizedHotelFilters +} + +export default function Filter({ filters }: FilterProps) { + const intl = useIntl() + const { facilityFilters, surroundingsFilters, countryFilters } = filters + const { pendingFilters, togglePendingFilter } = useHotelListingDataStore( + (state) => ({ + pendingFilters: state.pendingFilters, + togglePendingFilter: state.actions.togglePendingFilter, + }) + ) + + if ( + !facilityFilters.length && + !surroundingsFilters.length && + !countryFilters.length + ) { + return null + } + + return ( +
+ +

+ {intl.formatMessage({ + defaultMessage: "Filter by", + })} +

+
+
+
+ + + {intl.formatMessage({ + defaultMessage: "Country", + })} + + +
    + {countryFilters.map((filter) => ( +
  • + togglePendingFilter(filter.slug)} + isSelected={!!pendingFilters.find((f) => f === filter.slug)} + /> +
  • + ))} +
+
+ +
+ + + {intl.formatMessage({ + defaultMessage: "Hotel facilities", + })} + + +
    + {facilityFilters.map((filter) => ( +
  • + togglePendingFilter(filter.slug)} + isSelected={!!pendingFilters.find((f) => f === filter.slug)} + /> +
  • + ))} +
+
+ +
+ + + {intl.formatMessage({ + defaultMessage: "Hotel surroundings", + })} + + +
    + {surroundingsFilters.map((filter) => ( +
  • + togglePendingFilter(filter.slug)} + isSelected={!!pendingFilters.find((f) => f === filter.slug)} + /> +
  • + ))} +
+
+
+
+ ) +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/Sort/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/Sort/index.tsx new file mode 100644 index 000000000..3d8f36956 --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/Sort/index.tsx @@ -0,0 +1,40 @@ +"use client" + +import { useIntl } from "react-intl" + +import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect" + +import { useHotelListingDataStore } from "@/stores/hotel-listing-data" + +import type { + HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" + +interface SortProps { + sortItems: HotelSortItem[] +} + +export default function Sort({ sortItems }: SortProps) { + const intl = useIntl() + const { pendingSort, setPendingSort } = useHotelListingDataStore((state) => ({ + pendingSort: state.pendingSort, + setPendingSort: state.actions.setPendingSort, + })) + + return ( + setPendingSort(sort as HotelSortOption)} + /> + ) +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/hotelFilterAndSort.module.css b/apps/scandic-web/components/HotelFilterAndSort/hotelFilterAndSort.module.css new file mode 100644 index 000000000..f3d912615 --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/hotelFilterAndSort.module.css @@ -0,0 +1,115 @@ +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + align-items: center; + z-index: var(--default-modal-overlay-z-index); +} + +.dialog { + width: min(80dvw, 960px); + border-radius: var(--Corner-radius-lg); + background-color: var(--Base-Surface-Primary-light-Normal); + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + overflow: hidden; +} + +.header { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: var(--Space-x2) var(--Space-x3); + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +.heading { + text-align: center; +} + +.buttonWrapper { + display: flex; + gap: var(--Space-x1); + align-items: center; +} + +.badge { + background-color: var(--Base-Text-Accent); + border-radius: var(--Corner-radius-xl); + width: 20px; + height: 20px; + color: var(--Base-Surface-Primary-light-Normal); + display: flex; + align-items: center; + justify-content: center; +} + +.content { + display: grid; + gap: var(--Space-x4); + align-content: start; + padding: var(--Space-x4) var(--Space-x3); + overflow-y: auto; + height: min(calc(80dvh - 180px), 500px); +} + +.alertWrapper:not(:empty) { + padding: var(--Space-x2) var(--Space-x4) 0; + border-top: 1px solid var(--Base-Border-Subtle); +} + +.alertWrapper:not(:empty) + .footer { + border-top: none; +} + +.footer { + display: flex; + justify-content: space-between; + padding: var(--Space-x2) var(--Space-x4); + border-top: 1px solid var(--Base-Border-Subtle); +} + +@media screen and (max-width: 767px) { + .overlay { + height: var(--visual-viewport-height); + } + + .dialog { + display: flex; + flex-direction: column; + height: 100dvh; + width: 100vw; + border-radius: 0; + } + + .header { + display: flex; + justify-content: flex-end; + border-bottom: none; + padding: var(--Space-x3) var(--Space-x2); + } + + .title, + .divider { + display: none; + } + + .content { + height: 100%; + padding: 0 var(--Space-x2) var(--Space-x3); + overflow-y: scroll; + } + + .alertWrapper:not(:empty) { + padding: var(--Space-x3) var(--Space-x2) 0; + } + + .footer { + flex-direction: column-reverse; + gap: var(--Space-x3); + padding: var(--Space-x3) var(--Space-x2); + margin-top: auto; + } +} diff --git a/apps/scandic-web/components/HotelFilterAndSort/index.tsx b/apps/scandic-web/components/HotelFilterAndSort/index.tsx new file mode 100644 index 000000000..4e96660fa --- /dev/null +++ b/apps/scandic-web/components/HotelFilterAndSort/index.tsx @@ -0,0 +1,178 @@ +"use client" + +import { useRouter } from "next/navigation" +import { + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { Button } from "@scandic-hotels/design-system/Button" +import { Divider } from "@scandic-hotels/design-system/Divider" +import { IconButton } from "@scandic-hotels/design-system/IconButton" +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" +import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType" + +import { useHotelListingDataStore } from "@/stores/hotel-listing-data" + +import Alert from "../TempDesignSystem/Alert" +import Filter from "./Filter" +import Sort from "./Sort" + +import styles from "./hotelFilterAndSort.module.css" + +export default function HotelFilterAndSort() { + const intl = useIntl() + const router = useRouter() + const { + filters, + sortItems, + pendingFilters, + pendingSort, + defaultSort, + pendingCount, + activeFilters, + clearPendingFilters, + resetPendingValues, + setIsLoading, + } = useHotelListingDataStore((state) => ({ + filters: state.allFilters, + sortItems: state.sortItems, + pendingFilters: state.pendingFilters, + pendingSort: state.pendingSort, + defaultSort: state.defaultSort, + pendingCount: state.pendingHotelCount, + activeFilters: state.activeFilters, + clearPendingFilters: state.actions.clearPendingFilters, + resetPendingValues: state.actions.resetPendingValues, + setIsLoading: state.actions.setIsLoading, + })) + const alertHeading = intl.formatMessage({ + defaultMessage: "No matching hotels found", + }) + + const alertText = intl.formatMessage({ + defaultMessage: + "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.", + }) + + function submitAndClose(close: () => void) { + setIsLoading(true) + const sort = pendingSort + const filters = pendingFilters + const parsedUrl = new URL(window.location.href) + const searchParams = parsedUrl.searchParams + if (sort === defaultSort && searchParams.has("sort")) { + searchParams.delete("sort") + } else if (sort !== defaultSort) { + searchParams.set("sort", sort) + } + + if (!filters.length && searchParams.has("filter")) { + searchParams.delete("filter") + } else if (filters.length) { + searchParams.set("filter", filters.join(",")) + } + + router.push(parsedUrl.toString(), { scroll: false }) + close() + } + + function handleClose(isOpen: boolean) { + if (isOpen) { + resetPendingValues() + } + } + + return ( + +
+ + {activeFilters.length > 0 && ( + + {activeFilters.length} + + )} +
+ + + + {({ close }) => ( + <> +
+ +

+ {intl.formatMessage({ + defaultMessage: "Filter and sort", + })} +

+
+ + + +
+
+ + + +
+ {pendingCount === 0 && ( +
+ +
+ )} +
+ + +
+ + )} +
+
+
+
+ ) +} diff --git a/apps/scandic-web/contexts/HotelListingData.ts b/apps/scandic-web/contexts/HotelListingData.ts new file mode 100644 index 000000000..3f0aa81a4 --- /dev/null +++ b/apps/scandic-web/contexts/HotelListingData.ts @@ -0,0 +1,6 @@ +import { createContext } from "react" + +import type { HotelListingDataStore } from "@/types/contexts/hotel-listing-data" + +export const HotelListingDataContext = + createContext(null) diff --git a/apps/scandic-web/providers/HotelListingDataProvider/Content.tsx b/apps/scandic-web/providers/HotelListingDataProvider/Content.tsx new file mode 100644 index 000000000..505f46646 --- /dev/null +++ b/apps/scandic-web/providers/HotelListingDataProvider/Content.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useParams } from "next/navigation" +import { useEffect } from "react" + +import { useHotelListingDataStore } from "@/stores/hotel-listing-data" + +export default function HotelListingDataProviderContent({ + children, +}: React.PropsWithChildren) { + const params = useParams() + const { updateActiveFiltersAndSort, allFilterSlugs } = + useHotelListingDataStore((state) => ({ + allFilterSlugs: state.allFilterSlugs, + updateActiveFiltersAndSort: state.actions.updateActiveFiltersAndSort, + })) + + useEffect(() => { + const currentUrl = new URL(window.location.href) + const searchParams = currentUrl.searchParams + const sort = searchParams.get("sort") + const filterParam = searchParams.get("filter") + const activeFilters: string[] = [] + + if (filterParam) { + const filters = filterParam.split(",") + filters.forEach((filter) => { + if (allFilterSlugs.includes(filter)) { + activeFilters.push(filter) + } + }) + } + + updateActiveFiltersAndSort(activeFilters, sort) + }, [params, updateActiveFiltersAndSort, allFilterSlugs]) + + return <>{children} +} diff --git a/apps/scandic-web/providers/HotelListingDataProvider/index.tsx b/apps/scandic-web/providers/HotelListingDataProvider/index.tsx new file mode 100644 index 000000000..0f3db8059 --- /dev/null +++ b/apps/scandic-web/providers/HotelListingDataProvider/index.tsx @@ -0,0 +1,39 @@ +"use client" +import { useSearchParams } from "next/navigation" +import { useRef } from "react" + +import { createHotelListingDataStore } from "@/stores/hotel-listing-data" + +import { HotelListingDataContext } from "@/contexts/HotelListingData" + +import HotelListingDataProviderContent from "./Content" + +import type { HotelListingDataStore } from "@/types/contexts/hotel-listing-data" +import type { HotelListingDataProviderProps } from "@/types/providers/hotel-listing-data" + +export default function HotelListingDataProvider({ + allHotels, + allFilters, + sortItems, + children, +}: HotelListingDataProviderProps) { + const storeRef = useRef(undefined) + const searchParams = useSearchParams() + + if (!storeRef.current) { + storeRef.current = createHotelListingDataStore({ + allHotels, + allFilters, + sortItems, + searchParams, + }) + } + + return ( + + + {children} + + + ) +} diff --git a/apps/scandic-web/stores/destination-data/helper.ts b/apps/scandic-web/stores/destination-data/helper.ts index 04a2c4d33..30340cc20 100644 --- a/apps/scandic-web/stores/destination-data/helper.ts +++ b/apps/scandic-web/stores/destination-data/helper.ts @@ -1,29 +1,30 @@ -import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" +import { + type HotelListingHotelData, + type HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" - -import type { SortItem } from "@/types/components/destinationFilterAndSort" const HOTEL_SORTING_STRATEGIES: Partial< Record< - SortOption, - (a: DestinationPagesHotelData, b: DestinationPagesHotelData) => number + HotelSortOption, + (a: HotelListingHotelData, b: HotelListingHotelData) => number > > = { - [SortOption.Name]: function (a, b) { + [HotelSortOption.Name]: function (a, b) { return a.hotel.name.localeCompare(b.hotel.name) }, - [SortOption.TripAdvisorRating]: function (a, b) { + [HotelSortOption.TripAdvisorRating]: function (a, b) { return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0) }, - [SortOption.Distance]: function (a, b) { + [HotelSortOption.Distance]: function (a, b) { return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre }, } export function getFilteredHotels( - hotels: DestinationPagesHotelData[], + hotels: HotelListingHotelData[], filters: string[] ) { if (filters.length) { @@ -37,7 +38,7 @@ export function getFilteredHotels( } export function getFilteredCities( - filteredHotels: DestinationPagesHotelData[], + filteredHotels: HotelListingHotelData[], cities: DestinationCityListItem[] ) { const filteredCityIdentifiers = filteredHotels.map( @@ -52,8 +53,8 @@ export function getFilteredCities( } export function getSortedHotels( - hotels: DestinationPagesHotelData[], - sortOption: SortOption + hotels: HotelListingHotelData[], + sortOption: HotelSortOption ) { const sortFn = HOTEL_SORTING_STRATEGIES[sortOption] return sortFn ? [...hotels].sort(sortFn) : hotels @@ -61,9 +62,9 @@ export function getSortedHotels( export function isValidSortOption( value: string, - sortItems: SortItem[] -): value is SortOption { - return sortItems.map((item) => item.value).includes(value as SortOption) + sortItems: HotelSortItem[] +): value is HotelSortOption { + return sortItems.map((item) => item.value).includes(value as HotelSortOption) } export function getBasePathNameWithoutFilters( diff --git a/apps/scandic-web/stores/destination-data/index.ts b/apps/scandic-web/stores/destination-data/index.ts index d9e7dc3bc..cfd7d97f7 100644 --- a/apps/scandic-web/stores/destination-data/index.ts +++ b/apps/scandic-web/stores/destination-data/index.ts @@ -18,7 +18,7 @@ import { isValidSortOption, } from "./helper" -import type { Filter } from "@scandic-hotels/trpc/types/destinationFilterAndSort" +import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel" import type { DestinationDataState, @@ -36,8 +36,8 @@ export function createDestinationDataStore({ }: InitialState) { const defaultSort = sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value - const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) => - filter.map((f) => f.slug) + const allFilterSlugs = Object.values(allFilters).flatMap( + (filter: HotelFilter[]) => filter.map((f) => f.slug) ) const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : [] diff --git a/apps/scandic-web/stores/hotel-listing-data/helper.ts b/apps/scandic-web/stores/hotel-listing-data/helper.ts new file mode 100644 index 000000000..8867bbd4b --- /dev/null +++ b/apps/scandic-web/stores/hotel-listing-data/helper.ts @@ -0,0 +1,64 @@ +import { + type HotelListingHotelData, + type HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" + +const HOTEL_SORTING_STRATEGIES: Partial< + Record< + HotelSortOption, + (a: HotelListingHotelData, b: HotelListingHotelData) => number + > +> = { + [HotelSortOption.Name]: function (a, b) { + return a.hotel.name.localeCompare(b.hotel.name) + }, + [HotelSortOption.TripAdvisorRating]: function (a, b) { + return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0) + }, +} + +export function getFilteredHotels( + hotels: HotelListingHotelData[], + filters: string[] +) { + if (filters.length) { + return hotels.filter(({ hotel }) => + filters.every((filter) => { + const matchesFacility = hotel.detailedFacilities.some( + (facility) => facility.slug === filter + ) + const matchesCountry = + hotel.countryCode.toLowerCase() === filter.toLowerCase() + return matchesFacility || matchesCountry + }) + ) + } + return hotels +} + +export function getSortedHotels( + hotels: HotelListingHotelData[], + sortOption: HotelSortOption +) { + const sortFn = HOTEL_SORTING_STRATEGIES[sortOption] + return sortFn ? [...hotels].sort(sortFn) : hotels +} + +export function isValidSortOption( + value: string, + sortItems: HotelSortItem[] +): value is HotelSortOption { + return sortItems.map((item) => item.value).includes(value as HotelSortOption) +} + +export function getBasePathNameWithoutFilters( + pathname: string, + filterSlugs: string[] +) { + const pathSegments = pathname.split("/") + const filteredSegments = pathSegments.filter( + (segment) => !filterSlugs.includes(segment) + ) + return filteredSegments.join("/") +} diff --git a/apps/scandic-web/stores/hotel-listing-data/index.ts b/apps/scandic-web/stores/hotel-listing-data/index.ts new file mode 100644 index 000000000..ae4fe91e1 --- /dev/null +++ b/apps/scandic-web/stores/hotel-listing-data/index.ts @@ -0,0 +1,175 @@ +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" + +import { HotelListingDataContext } from "@/contexts/HotelListingData" +import { + trackFilterChangeEvent, + trackSortingChangeEvent, +} from "@/utils/tracking/destinationPage" + +import { getFilteredHotels, getSortedHotels, isValidSortOption } from "./helper" + +import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel" + +import type { + HotelListingDataState, + InitialState, +} from "@/types/stores/hotel-listing-data" + +export function createHotelListingDataStore({ + allHotels, + allFilters, + sortItems, + searchParams, +}: 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 activeFilters: string[] = [] + + let activeSort = defaultSort + if (searchParams) { + const sortParam = searchParams.get("sort") + const filterParam = searchParams.get("filter") + + if (sortParam && isValidSortOption(sortParam, sortItems)) { + activeSort = sortParam + } + + if (filterParam) { + const filters = filterParam.split(",") + filters.forEach((filter) => { + if (allFilterSlugs.includes(filter)) { + activeFilters.push(filter) + } + }) + } + } + const filteredHotels = getFilteredHotels(allHotels, activeFilters) + const activeHotels = getSortedHotels(filteredHotels, activeSort) + + return create((set) => ({ + actions: { + updateActiveFiltersAndSort(filters, sort) { + return set( + produce((state: HotelListingDataState) => { + const newSort = + sort && isValidSortOption(sort, state.sortItems) + ? sort + : state.defaultSort + const filteredHotels = getFilteredHotels(state.allHotels, filters) + const sortedHotels = getSortedHotels(filteredHotels, newSort) + + // Tracking + if (newSort !== state.activeSort) { + trackSortingChangeEvent(newSort) + } + 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) + ) + + trackFilterChangeEvent( + facilityFiltersUsed, + surroundingsFiltersUsed + ) + } + + state.activeSort = newSort + state.activeFilters = filters + state.activeHotels = sortedHotels + + state.pendingFilters = filters + state.pendingSort = newSort + state.pendingHotelCount = filteredHotels.length + state.isLoading = false + }) + ) + }, + setIsLoading(isLoading) { + return set( + produce((state: HotelListingDataState) => { + state.isLoading = isLoading + }) + ) + }, + setPendingSort(sort) { + return set( + produce((state: HotelListingDataState) => { + state.pendingSort = sort + }) + ) + }, + togglePendingFilter(filter) { + return set( + produce((state: HotelListingDataState) => { + const isActive = state.pendingFilters.includes(filter) + const filters = isActive + ? state.pendingFilters.filter((f) => f !== filter) + : [...state.pendingFilters, filter] + const pendingHotels = getFilteredHotels(state.allHotels, filters) + + state.pendingFilters = filters + state.pendingHotelCount = pendingHotels.length + }) + ) + }, + clearPendingFilters() { + return set( + produce((state: HotelListingDataState) => { + state.pendingFilters = [] + state.pendingHotelCount = state.allHotels.length + }) + ) + }, + resetPendingValues() { + return set( + produce((state: HotelListingDataState) => { + state.pendingFilters = state.activeFilters + state.pendingSort = state.activeSort + state.pendingHotelCount = state.activeHotels.length + }) + ) + }, + }, + allHotels, + activeHotels: activeHotels, + pendingHotelCount: activeHotels.length, + activeSort, + pendingSort: activeSort, + defaultSort, + activeFilters, + pendingFilters: activeFilters, + allFilters, + allFilterSlugs, + sortItems, + isLoading: false, + })) +} + +export function useHotelListingDataStore( + selector: (store: HotelListingDataState) => T +) { + const store = useContext(HotelListingDataContext) + + if (!store) { + throw new Error( + "useHotelListingDataStore must be used within HotelListingDataProvider" + ) + } + + return useStore(store, selector) +} diff --git a/apps/scandic-web/types/components/contentPage/hotelListingItem.ts b/apps/scandic-web/types/components/contentPage/hotelListingItem.ts index dd957efea..28a476111 100644 --- a/apps/scandic-web/types/components/contentPage/hotelListingItem.ts +++ b/apps/scandic-web/types/components/contentPage/hotelListingItem.ts @@ -1,10 +1,8 @@ -import type { AdditionalData, Hotel } from "@scandic-hotels/trpc/types/hotel" +import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel" import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks" export interface HotelListingItemProps { - hotel: Hotel - additionalData: AdditionalData + hotelData: HotelListingHotelData contentType: HotelListing["contentType"] - url: string | null } diff --git a/apps/scandic-web/types/components/destinationFilterAndSort.ts b/apps/scandic-web/types/components/destinationFilterAndSort.ts deleted file mode 100644 index ab68133ad..000000000 --- a/apps/scandic-web/types/components/destinationFilterAndSort.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" - -export interface SortItem { - label: string - value: SortOption - isDefault?: boolean -} diff --git a/apps/scandic-web/types/components/maps/destinationMarkers.ts b/apps/scandic-web/types/components/maps/destinationMarkers.ts index 09729a8ef..889283ec1 100644 --- a/apps/scandic-web/types/components/maps/destinationMarkers.ts +++ b/apps/scandic-web/types/components/maps/destinationMarkers.ts @@ -1,6 +1,6 @@ +import type { Amenities } from "@scandic-hotels/trpc/types/hotel" import type { FeatureCollection, Point } from "geojson" -import type { Amenities } from "@scandic-hotels/trpc/types/hotel" import type { GalleryImage } from "../imageGallery" export interface DestinationMarker { @@ -9,7 +9,7 @@ export interface DestinationMarker { name: string coordinates: google.maps.LatLngLiteral url: string - tripadvisor: number | undefined + tripadvisor: number | null amenities: Amenities image: GalleryImage } diff --git a/apps/scandic-web/types/contexts/hotel-listing-data.ts b/apps/scandic-web/types/contexts/hotel-listing-data.ts new file mode 100644 index 000000000..f43c36b56 --- /dev/null +++ b/apps/scandic-web/types/contexts/hotel-listing-data.ts @@ -0,0 +1,5 @@ +import type { createHotelListingDataStore } from "@/stores/hotel-listing-data" + +export type HotelListingDataStore = ReturnType< + typeof createHotelListingDataStore +> diff --git a/apps/scandic-web/types/providers/destination-data.ts b/apps/scandic-web/types/providers/destination-data.ts index be349dc3f..0d46d2dd2 100644 --- a/apps/scandic-web/types/providers/destination-data.ts +++ b/apps/scandic-web/types/providers/destination-data.ts @@ -1,14 +1,15 @@ import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" -import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" - -import type { SortItem } from "../components/destinationFilterAndSort" +import type { + CategorizedHotelFilters, + HotelListingHotelData, + HotelSortItem, +} from "@scandic-hotels/trpc/types/hotel" export interface DestinationDataProviderProps extends React.PropsWithChildren { - allHotels: DestinationPagesHotelData[] + allHotels: HotelListingHotelData[] allCities?: DestinationCityListItem[] - allFilters: CategorizedFilters + allFilters: CategorizedHotelFilters filterFromUrl?: string - sortItems: SortItem[] + 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 new file mode 100644 index 000000000..bc52c83b9 --- /dev/null +++ b/apps/scandic-web/types/providers/hotel-listing-data.ts @@ -0,0 +1,11 @@ +import type { + CategorizedHotelFilters, + HotelListingHotelData, + HotelSortItem, +} from "@scandic-hotels/trpc/types/hotel" + +export interface HotelListingDataProviderProps extends React.PropsWithChildren { + allHotels: HotelListingHotelData[] + allFilters: CategorizedHotelFilters + sortItems: HotelSortItem[] +} diff --git a/apps/scandic-web/types/stores/destination-data.ts b/apps/scandic-web/types/stores/destination-data.ts index 5ccdcfa36..588c1e59a 100644 --- a/apps/scandic-web/types/stores/destination-data.ts +++ b/apps/scandic-web/types/stores/destination-data.ts @@ -1,14 +1,15 @@ -import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort" import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage" -import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort" -import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel" +import type { + CategorizedHotelFilters, + HotelListingHotelData, + HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" import type { ReadonlyURLSearchParams } from "next/navigation" -import type { SortItem } from "../components/destinationFilterAndSort" - interface Actions { updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void - setPendingSort: (sort: SortOption) => void + setPendingSort: (sort: HotelSortOption) => void togglePendingFilter: (filter: string) => void clearPendingFilters: () => void resetPendingValues: () => void @@ -16,8 +17,8 @@ interface Actions { } export interface SubmitCallbackData { - sort: SortOption - defaultSort: SortOption + sort: HotelSortOption + defaultSort: HotelSortOption filters: string[] basePath: string } @@ -25,19 +26,19 @@ export interface DestinationDataState { actions: Actions allCities: DestinationCityListItem[] activeCities: DestinationCityListItem[] - allHotels: DestinationPagesHotelData[] - activeHotels: DestinationPagesHotelData[] - pendingSort: SortOption - activeSort: SortOption - defaultSort: SortOption + allHotels: HotelListingHotelData[] + activeHotels: HotelListingHotelData[] + pendingSort: HotelSortOption + activeSort: HotelSortOption + defaultSort: HotelSortOption pendingFilters: string[] activeFilters: string[] pendingHotelCount: number pendingCityCount: number - allFilters: CategorizedFilters + allFilters: CategorizedHotelFilters allFilterSlugs: string[] basePathnameWithoutFilters: string - sortItems: SortItem[] + sortItems: HotelSortItem[] isLoading: boolean } diff --git a/apps/scandic-web/types/stores/hotel-listing-data.ts b/apps/scandic-web/types/stores/hotel-listing-data.ts new file mode 100644 index 000000000..8dbd5c7eb --- /dev/null +++ b/apps/scandic-web/types/stores/hotel-listing-data.ts @@ -0,0 +1,46 @@ +import type { + CategorizedHotelFilters, + HotelListingHotelData, + HotelSortItem, + HotelSortOption, +} from "@scandic-hotels/trpc/types/hotel" +import type { ReadonlyURLSearchParams } from "next/navigation" + +interface Actions { + updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void + setPendingSort: (sort: HotelSortOption) => void + togglePendingFilter: (filter: string) => void + clearPendingFilters: () => void + resetPendingValues: () => void + setIsLoading: (isLoading: boolean) => void +} + +export interface SubmitCallbackData { + sort: HotelSortOption + defaultSort: HotelSortOption + filters: string[] + basePath: string +} +export interface HotelListingDataState { + actions: Actions + allHotels: HotelListingHotelData[] + activeHotels: HotelListingHotelData[] + pendingSort: HotelSortOption + activeSort: HotelSortOption + defaultSort: HotelSortOption + pendingFilters: string[] + activeFilters: string[] + pendingHotelCount: number + allFilters: CategorizedHotelFilters + allFilterSlugs: string[] + sortItems: HotelSortItem[] + isLoading: boolean +} + +export interface InitialState + extends Pick< + HotelListingDataState, + "allHotels" | "sortItems" | "allFilters" + > { + searchParams: ReadonlyURLSearchParams +} diff --git a/packages/trpc/lib/enums/destinationFilterAndSort.ts b/packages/trpc/lib/enums/destinationFilterAndSort.ts deleted file mode 100644 index 3b2961bee..000000000 --- a/packages/trpc/lib/enums/destinationFilterAndSort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum SortOption { - Recommended = "recommended", - Distance = "distance", - Name = "name", - TripAdvisorRating = "tripadvisor", -} diff --git a/packages/trpc/lib/routers/contentstack/metadata/helpers.ts b/packages/trpc/lib/routers/contentstack/metadata/helpers.ts deleted file mode 100644 index db7dbf387..000000000 --- a/packages/trpc/lib/routers/contentstack/metadata/helpers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { - CategorizedFilters, - Filter, -} from "../../../types/destinationFilterAndSort" -import type { DestinationPagesHotelData } 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", -] - -export function getFiltersFromHotels( - hotels: DestinationPagesHotelData[] -): CategorizedFilters { - if (hotels.length === 0) { - return { facilityFilters: [], surroundingsFilters: [] } - } - - const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities) - const uniqueFilterNames = [...new Set(filters.map((filter) => filter.name))] - const filterList = uniqueFilterNames - .map((filterName) => { - const filter = filters.find((filter) => filter.name === filterName) - return filter - ? { - name: filter.name, - slug: filter.slug, - filterType: filter.filter, - sortOrder: filter.sortOrder, - } - : null - }) - .filter((filter): filter is Filter => !!filter) - - const facilityFilters = filterList.filter((filter) => - HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType) - ) - const surroundingsFilters = filterList.filter((filter) => - HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType) - ) - return { - facilityFilters: sortFilters(facilityFilters), - surroundingsFilters: sortFilters(surroundingsFilters), - } -} - -function sortFilters(filters: Filter[]): Filter[] { - 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 - }) -} diff --git a/packages/trpc/lib/routers/contentstack/metadata/utils.ts b/packages/trpc/lib/routers/contentstack/metadata/utils.ts index 5a8cd1dae..f77e9d204 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/utils.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/utils.ts @@ -1,5 +1,6 @@ -import { SortOption } from "../../../enums/destinationFilterAndSort" import { ApiCountry } from "../../../types/country" +import { HotelSortOption } from "../../../types/hotel" +import { getFiltersFromHotels } from "../../../utils/getFiltersFromHotels" import { getSortedCities } from "../../../utils/getSortedCities" import { getCityByCityIdentifier, @@ -8,7 +9,6 @@ import { getHotelsByHotelIds, } from "../../hotels/utils" import { getCityPages } from "../destinationCountryPage/utils" -import { getFiltersFromHotels } from "./helpers" import type { Lang } from "@scandic-hotels/common/constants/language" @@ -61,7 +61,7 @@ export async function getCityData( let filterType if (filter) { - const allFilters = getFiltersFromHotels(hotels) + const allFilters = getFiltersFromHotels(hotels, lang) const facilityFilter = allFilters.facilityFilters.find( (f) => f.slug === filter ) @@ -101,7 +101,7 @@ export async function getCountryData( let filterType const cities = await getCityPages(lang, serviceToken, country) - const sortedCities = getSortedCities(cities, SortOption.Recommended) + const sortedCities = getSortedCities(cities, HotelSortOption.Recommended) const hotelIds = await getHotelIdsByCountry({ country, serviceToken, @@ -110,7 +110,7 @@ export async function getCountryData( const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) if (filter) { - const allFilters = getFiltersFromHotels(hotels) + const allFilters = getFiltersFromHotels(hotels, lang) const facilityFilter = allFilters.facilityFilters.find( (f) => f.slug === filter ) diff --git a/packages/trpc/lib/routers/hotels/input.ts b/packages/trpc/lib/routers/hotels/input.ts index 9e3412009..f5ec1430f 100644 --- a/packages/trpc/lib/routers/hotels/input.ts +++ b/packages/trpc/lib/routers/hotels/input.ts @@ -239,9 +239,13 @@ export const getHotelsByCSFilterInput = z.object({ }) .nullish(), hotelsToInclude: z.array(z.string()), + contentType: z + .enum(["hotel", "restaurant", "meeting"]) + .optional() + .default("hotel"), }) export interface GetHotelsByCSFilterInput - extends z.infer {} + extends z.input {} export const nearbyHotelIdsInput = z.object({ hotelId: z.string(), diff --git a/packages/trpc/lib/routers/hotels/output.ts b/packages/trpc/lib/routers/hotels/output.ts index 87c24329d..92ae65bed 100644 --- a/packages/trpc/lib/routers/hotels/output.ts +++ b/packages/trpc/lib/routers/hotels/output.ts @@ -616,40 +616,24 @@ export const roomFeaturesSchema = z return data.data.attributes.roomFeatures }) -export const destinationPagesHotelDataSchema = z - .object({ - data: z.object({ - id: z.string(), - name: z.string(), - location: locationSchema, - cityIdentifier: z.string().optional(), - tripadvisor: z.number().optional(), - detailedFacilities: detailedFacilitiesSchema, - galleryImages: z - .array(imageSchema) - .nullish() - .transform((arr) => (arr ? arr.filter(Boolean) : [])), - address: addressSchema, - hotelType: z.string(), - type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels". - url: z.string().optional(), - hotelContent: z - .object({ - texts: z.object({ - descriptions: z.object({ - short: z.string().optional(), - }), - }), - }) - .optional(), - }), - }) - .transform(({ data: { hotelContent, ...data } }) => { - return { - hotel: { - ...data, - hotelDescription: hotelContent?.texts.descriptions?.short, - }, - url: data.url ?? "", - } - }) +export const hotelListingHotelDataSchema = z.object({ + hotel: z.object({ + id: z.string(), + name: z.string(), + countryCode: z.string(), + location: locationSchema, + cityIdentifier: z.string().nullable(), + tripadvisor: z.number().nullable(), + detailedFacilities: detailedFacilitiesSchema, + galleryImages: z + .array(imageSchema) + .nullish() + .transform((arr) => (arr ? arr.filter(Boolean) : [])), + address: addressSchema, + hotelType: z.string(), + type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels". + description: z.string().nullable(), + }), + url: z.string().nullable(), + meetingUrl: z.string().nullable(), +}) diff --git a/packages/trpc/lib/routers/hotels/query.ts b/packages/trpc/lib/routers/hotels/query.ts index 2a49219ea..cafceabca 100644 --- a/packages/trpc/lib/routers/hotels/query.ts +++ b/packages/trpc/lib/routers/hotels/query.ts @@ -53,19 +53,17 @@ import { getVerifiedUser } from "../user/utils" import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { meetingRoomsSchema } from "./schemas/meetingRoom" import { + getBedTypes, getCitiesByCountry, getCountries, getHotel, getHotelIdsByCityId, getHotelIdsByCityIdentifier, getHotelIdsByCountry, - getHotelsByHotelIds, - getLocations, -} from "./utils" -import { - getBedTypes, getHotelsAvailabilityByCity, getHotelsAvailabilityByHotelIds, + getHotelsByHotelIds, + getLocations, getPackages, getRoomsAvailability, getSelectedRoomAvailability, @@ -73,10 +71,7 @@ import { selectRateRedirectURL, } from "./utils" -import type { - DestinationPagesHotelData, - HotelDataWithUrl, -} from "../../types/hotel" +import type { HotelListingHotelData } from "../../types/hotel" import type { CityLocation } from "../../types/locations" import type { Room } from "../../types/room" @@ -570,7 +565,7 @@ export const hotelQueryRouter = router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCSFilterInput) .query(async function ({ ctx, input }) { - const { locationFilter, hotelsToInclude } = input + const { locationFilter, hotelsToInclude, contentType } = input const language = ctx.lang let hotelsToFetch: string[] = [] @@ -669,29 +664,16 @@ export const hotelQueryRouter = router({ return [] } - const hotelPages = await getHotelPageUrls(language) - const hotels = await Promise.all( - hotelsToFetch.map(async (hotelId) => { - const hotelData = await getHotel( - { hotelId, isCardOnlyPayment: false, language }, - ctx.serviceToken - ) - const hotelPage = hotelPages.find( - (page) => page.hotelId === hotelId - ) - - return hotelData - ? { - ...hotelData, - url: hotelPage?.url ?? null, - } - : null - }) - ) + const hotels = await getHotelsByHotelIds({ + hotelIds: hotelsToFetch, + lang: language, + serviceToken: ctx.serviceToken, + contentType, + }) metricsGetHotelsByCSFilter.success() - return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) + return hotels }), }), getDestinationsMapData: serviceProcedure @@ -713,7 +695,7 @@ export const hotelQueryRouter = router({ } const countryNames = countries.data.map((country) => country.name) - const hotelData: DestinationPagesHotelData[] = ( + const hotelData: HotelListingHotelData[] = ( await Promise.all( countryNames.map(async (country) => { const hotelIds = await getHotelIdsByCountry({ diff --git a/packages/trpc/lib/routers/hotels/utils.ts b/packages/trpc/lib/routers/hotels/utils.ts index 7737cf4ce..000891733 100644 --- a/packages/trpc/lib/routers/hotels/utils.ts +++ b/packages/trpc/lib/routers/hotels/utils.ts @@ -48,8 +48,11 @@ import type { RoomsAvailabilityOutputSchema, } from "../../types/availability" import type { BedTypeSelection } from "../../types/bedTypeSelection" -import type { Room as RoomCategory } from "../../types/hotel" -import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel" +import type { + HotelInput, + HotelListingHotelData, + Room as RoomCategory, +} from "../../types/hotel" import type { CitiesGroupedByCountry, CityLocation, @@ -348,13 +351,15 @@ export async function getHotelsByHotelIds({ hotelIds, lang, serviceToken, + contentType = "hotel", }: { hotelIds: string[] lang: Lang serviceToken: string + contentType?: "hotel" | "restaurant" | "meeting" }) { const cacheClient = await getCacheClient() - const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}` + const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${contentType}:${hotelIds.sort().join(",")}` return await cacheClient.cacheOrGet( cacheKey, @@ -362,7 +367,7 @@ export async function getHotelsByHotelIds({ const hotelPages = await getHotelPageUrls(lang) const chunkedHotelIds = chunk(hotelIds, 10) - const hotels: DestinationPagesHotelData[] = [] + const hotels: HotelListingHotelData[] = [] for (const hotelIdChunk of chunkedHotelIds) { const chunkedHotels = await Promise.all( hotelIdChunk.map(async (hotelId) => { @@ -378,22 +383,59 @@ export async function getHotelsByHotelIds({ const hotelPage = hotelPages.find( (page) => page.hotelId === hotelId ) - const { hotel, cities } = hotelResponse - const data: DestinationPagesHotelData = { + const { hotel, cities, additionalData } = hotelResponse + + const content = { + description: hotel.hotelContent?.texts.descriptions?.short, + galleryImages: hotel.galleryImages, + url: hotelPage?.url ?? "", + openInNewTab: false, + } + + if (contentType === "restaurant") { + const restaurantDescription = + additionalData?.restaurantsOverviewPage + .restaurantsContentDescriptionShort + const restaurantImages = + additionalData.restaurantImages?.heroImages + if (restaurantDescription) { + content.description = restaurantDescription + } + if (restaurantImages && restaurantImages.length > 0) { + content.galleryImages = restaurantImages + } + } else if (contentType === "meeting") { + const meetingDescription = + hotel.hotelContent.texts.meetingDescription?.short + const meetingImages = + additionalData?.conferencesAndMeetings?.heroImages + if (meetingDescription) { + content.description = meetingDescription + } + if (meetingImages && meetingImages.length > 0) { + content.galleryImages = meetingImages + } + } + + const data: HotelListingHotelData = { hotel: { id: hotel.id, - galleryImages: hotel.galleryImages, + countryCode: hotel.countryCode, + galleryImages: content.galleryImages, name: hotel.name, - tripadvisor: hotel.ratings?.tripAdvisor?.rating, - detailedFacilities: hotel.detailedFacilities || [], + tripadvisor: hotel.ratings?.tripAdvisor?.rating || null, + detailedFacilities: hotel.detailedFacilities.sort( + (a, b) => b.sortOrder - a.sortOrder + ), location: hotel.location, hotelType: hotel.hotelType, type: hotel.type, address: hotel.address, - cityIdentifier: cities?.[0]?.cityIdentifier, - hotelDescription: hotel.hotelContent?.texts.descriptions?.short, + cityIdentifier: cities[0]?.cityIdentifier || null, + description: content.description || null, }, - url: hotelPage?.url ?? "", + url: content.url, + meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null, } return data @@ -402,9 +444,7 @@ export async function getHotelsByHotelIds({ hotels.push(...chunkedHotels) } - return hotels.filter( - (hotel): hotel is DestinationPagesHotelData => !!hotel - ) + return hotels.filter((hotel): hotel is HotelListingHotelData => !!hotel) }, "1d" ) diff --git a/packages/trpc/lib/types/destinationFilterAndSort.ts b/packages/trpc/lib/types/destinationFilterAndSort.ts deleted file mode 100644 index 86ebf7ddd..000000000 --- a/packages/trpc/lib/types/destinationFilterAndSort.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Filter { - name: string - slug: string - filterType: string - sortOrder: number -} - -export interface CategorizedFilters { - facilityFilters: Filter[] - surroundingsFilters: Filter[] -} diff --git a/packages/trpc/lib/types/hotel.ts b/packages/trpc/lib/types/hotel.ts index a0f1e163a..93c690b28 100644 --- a/packages/trpc/lib/types/hotel.ts +++ b/packages/trpc/lib/types/hotel.ts @@ -5,7 +5,7 @@ import type { hotelInputSchema, } from "../routers/hotels/input" import type { - destinationPagesHotelDataSchema, + hotelListingHotelDataSchema, hotelSchema, } from "../routers/hotels/output" import type { citySchema } from "../routers/hotels/schemas/city" @@ -79,11 +79,35 @@ export type ExtraPageSchema = z.output export type HotelDataWithUrl = HotelData & { url: string } -export type DestinationPagesHotelData = z.output< - typeof destinationPagesHotelDataSchema -> +export type HotelListingHotelData = z.output export type CityCoordinatesInput = z.input export type HotelInput = z.input export type RoomType = Pick + +export interface HotelFilter { + name: string + slug: string + filterType: string + sortOrder: number +} + +export interface CategorizedHotelFilters { + facilityFilters: HotelFilter[] + surroundingsFilters: HotelFilter[] + countryFilters: HotelFilter[] +} + +export enum HotelSortOption { + Recommended = "recommended", + Distance = "distance", + Name = "name", + TripAdvisorRating = "tripadvisor", +} + +export interface HotelSortItem { + label: string + value: HotelSortOption + isDefault?: boolean +} diff --git a/packages/trpc/lib/utils/getFiltersFromHotels.ts b/packages/trpc/lib/utils/getFiltersFromHotels.ts new file mode 100644 index 000000000..552dc502f --- /dev/null +++ b/packages/trpc/lib/utils/getFiltersFromHotels.ts @@ -0,0 +1,110 @@ +import type { Lang } from "@scandic-hotels/common/constants/language" + +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 + }) +} + +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 + ? { + 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) { + console.error(`Could not map ${countryCode} to localized country.`) + return null + } + + return localizedCountry +} diff --git a/packages/trpc/lib/utils/getSortedCities.ts b/packages/trpc/lib/utils/getSortedCities.ts index d080bfcd0..022bbc24e 100644 --- a/packages/trpc/lib/utils/getSortedCities.ts +++ b/packages/trpc/lib/utils/getSortedCities.ts @@ -1,17 +1,17 @@ -import { SortOption } from "../enums/destinationFilterAndSort" +import { HotelSortOption } from "../types/hotel" import type { DestinationCityListItem } from "../types/destinationCityPage" const CITY_SORTING_STRATEGIES: Partial< Record< - SortOption, + HotelSortOption, (a: DestinationCityListItem, b: DestinationCityListItem) => number > > = { - [SortOption.Name]: function (a, b) { + [HotelSortOption.Name]: function (a, b) { return a.cityName.localeCompare(b.cityName) }, - [SortOption.Recommended]: function (a, b) { + [HotelSortOption.Recommended]: function (a, b) { if (a.sort_order === null && b.sort_order === null) { return a.cityName.localeCompare(b.cityName) } @@ -27,7 +27,7 @@ const CITY_SORTING_STRATEGIES: Partial< export function getSortedCities( cities: DestinationCityListItem[], - sortOption: SortOption + sortOption: HotelSortOption ) { const sortFn = CITY_SORTING_STRATEGIES[sortOption] return sortFn ? cities.sort(sortFn) : cities