Merged in feat/SW-1451-country-page-sorting (pull request #1426)

Feat/SW-1451 country page filtering and sorting

* feat(SW-1451): implemented sorting and filtering on country pages

* feat(SW-1451): Renamed hotel-data to destination-data because of its multi-purpose use

* feat(SW-1451): Now filtering after change of url instead of inside the store after submit


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-02-28 06:30:16 +00:00
parent 747201b0f7
commit bee6c6d83a
69 changed files with 1124 additions and 531 deletions

View File

@@ -64,8 +64,7 @@ export default async function ContentTypePage({
case PageContentTypeEnum.destinationCountryPage: case PageContentTypeEnum.destinationCountryPage:
return <DestinationCountryPage /> return <DestinationCountryPage />
case PageContentTypeEnum.destinationCityPage: case PageContentTypeEnum.destinationCityPage:
const filterFromUrl = searchParams.filterFromUrl return <DestinationCityPage />
return <DestinationCityPage filterFromUrl={filterFromUrl} />
case PageContentTypeEnum.hotelPage: case PageContentTypeEnum.hotelPage:
if (env.HIDE_FOR_NEXT_RELEASE) { if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound() return notFound()

View File

@@ -0,0 +1,50 @@
import {
getDestinationCityPagesByCountry,
getHotelsByCountry,
} from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import DestinationDataProvider from "@/providers/DestinationDataProvider"
import type { SortItem } from "@/types/components/destinationFilterAndSort"
import type { Country } from "@/types/enums/country"
import { SortOption } from "@/types/enums/destinationFilterAndSort"
interface CityDataContainerProps extends React.PropsWithChildren {
country: Country
}
export function preload(country: Country) {
void getHotelsByCountry(country)
void getDestinationCityPagesByCountry(country)
}
export default async function CityDataContainer({
country,
children,
}: CityDataContainerProps) {
const intl = await getIntl()
const [hotels, cities] = await Promise.all([
getHotelsByCountry(country),
getDestinationCityPagesByCountry(country),
])
const sortItems: SortItem[] = [
{
label: intl.formatMessage({ id: "Recommended" }),
value: SortOption.Recommended,
isDefault: true,
},
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
]
return (
<DestinationDataProvider
allHotels={hotels}
allCities={cities}
sortItems={sortItems}
>
{children}
</DestinationDataProvider>
)
}

View File

@@ -1,3 +1,5 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer" import SkeletonShimmer from "@/components/SkeletonShimmer"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
@@ -5,7 +7,7 @@ import ExperienceListSkeleton from "../../ExperienceList/ExperienceListSkeleton"
import styles from "./cityListingItem.module.css" import styles from "./cityListingItem.module.css"
export default async function CityListingItemSkeleton() { export default function CityListingItemSkeleton() {
return ( return (
<article className={styles.container}> <article className={styles.container}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>

View File

@@ -1,11 +1,13 @@
"use client"
import Link from "next/link" import Link from "next/link"
import { useIntl } from "react-intl"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery" import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
import ExperienceList from "../../ExperienceList" import ExperienceList from "../../ExperienceList"
@@ -18,8 +20,8 @@ interface CityListingItemProps {
city: DestinationCityListItem city: DestinationCityListItem
} }
export default async function CityListingItem({ city }: CityListingItemProps) { export default function CityListingItem({ city }: CityListingItemProps) {
const intl = await getIntl() const intl = useIntl()
const galleryImages = mapImageVaultImagesToGalleryImages(city.images) const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
return ( return (

View File

@@ -1,14 +1,17 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer" import SkeletonShimmer from "@/components/SkeletonShimmer"
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton" import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
import styles from "./cityListing.module.css" import styles from "./cityListing.module.css"
export default async function CityListingSkeleton() { export default function CityListingSkeleton() {
return ( return (
<section className={styles.container}> <section className={styles.container}>
<div> <div className={styles.listHeader}>
<SkeletonShimmer height="30px" width="300px" /> <SkeletonShimmer height="30px" width="200px" />
<SkeletonShimmer height="30px" width="120px" />
</div> </div>
<ul className={styles.cityList}> <ul className={styles.cityList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (

View File

@@ -1,6 +1,15 @@
.container { .container {
--scroll-margin-top: calc(
var(--booking-widget-mobile-height) + var(--Spacing-x2)
);
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
scroll-margin-top: var(--scroll-margin-top);
}
.listHeader {
display: flex;
justify-content: space-between;
} }
.cityList { .cityList {
@@ -8,3 +17,15 @@
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
} }
.cityList:not(.allVisible) li:nth-child(n + 6) {
display: none;
}
@media screen and (min-width: 768px) {
.container {
--scroll-margin-top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2)
);
}
}

View File

@@ -1,45 +1,83 @@
import { getDestinationCityPagesByCountry } from "@/lib/trpc/memoizedRequests" "use client"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n" import { useScrollToTop } from "@/hooks/useScrollToTop"
import CityListingItem from "./CityListingItem" import CityListingItem from "./CityListingItem"
import CityListingSkeleton from "./CityListingSkeleton"
import styles from "./cityListing.module.css" import styles from "./cityListing.module.css"
import type { Country } from "@/types/enums/country" export default function CityListing() {
const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300,
elementRef: scrollRef,
})
const { activeCities, filters, sortItems, isLoading } =
useDestinationDataStore((state) => ({
activeCities: state.activeCities,
filters: state.allFilters,
sortItems: state.sortItems,
isLoading: state.isLoading,
}))
const [allCitiesVisible, setAllCitiesVisible] = useState(
activeCities.length <= 5
)
interface CityListingProps { function handleShowMore() {
country: Country if (scrollRef.current && allCitiesVisible) {
} scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
export default async function CityListing({ country }: CityListingProps) { setAllCitiesVisible((state) => !state)
const intl = await getIntl()
const cities = await getDestinationCityPagesByCountry(country)
if (!cities.length) {
return null
} }
return ( return isLoading ? (
<section className={styles.container}> <CityListingSkeleton />
<div> ) : (
<section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}>
<Subtitle type="two"> <Subtitle type="two">
{intl.formatMessage( {intl.formatMessage(
{ {
id: `{count, plural, one {{count} Location} other {{count} Locations}}`, id: `{count, plural, one {{count} Location} other {{count} Locations}}`,
}, },
{ count: cities.length } { count: activeCities.length }
)} )}
</Subtitle> </Subtitle>
<DestinationFilterAndSort
filters={filters}
sortItems={sortItems}
listType="city"
/>
</div> </div>
<ul className={styles.cityList}> <ul
{cities.map((city) => ( className={`${styles.cityList} ${allCitiesVisible ? styles.allVisible : ""}`}
>
{activeCities.map((city) => (
<li key={city.system.uid}> <li key={city.system.uid}>
<CityListingItem city={city} /> <CityListingItem city={city} />
</li> </li>
))} ))}
</ul> </ul>
{activeCities.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allCitiesVisible}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</section> </section>
) )
} }

View File

@@ -0,0 +1,23 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import HotelListItemSkeleton from "../HotelListItem/HotelListItemSkeleton"
import styles from "./hotelList.module.css"
export default function HotelListSkeleton() {
return (
<div className={styles.hotelListWrapper}>
<div className={styles.header}>
<SkeletonShimmer height="30px" width="200px" />
<SkeletonShimmer height="30px" width="120px" />
</div>
<ul className={styles.hotelList}>
{Array.from({ length: 3 }).map((_, index) => (
<HotelListItemSkeleton key={index} />
))}
</ul>
</div>
)
}

View File

@@ -4,13 +4,14 @@ import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import HotelFilterAndSort from "@/components/HotelFilterAndSort" import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import { debounce } from "@/utils/debounce" import { debounce } from "@/utils/debounce"
import HotelListItem from "../HotelListItem" import HotelListItem from "../HotelListItem"
import HotelListSkeleton from "./HotelListSkeleton"
import { getVisibleHotels } from "./utils" import { getVisibleHotels } from "./utils"
import styles from "./hotelList.module.css" import styles from "./hotelList.module.css"
@@ -22,11 +23,13 @@ export default function HotelList() {
const map = useMap() const map = useMap()
const coreLib = useMapsLibrary("core") const coreLib = useMapsLibrary("core")
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([]) const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
const { filters, sortItems, activeHotels } = useHotelDataStore((state) => ({ const { filters, sortItems, activeHotels, isLoading } =
filters: state.allFilters, useDestinationDataStore((state) => ({
sortItems: state.sortItems, filters: state.allFilters,
activeHotels: state.activeHotels, sortItems: state.sortItems,
})) activeHotels: state.activeHotels,
isLoading: state.isLoading,
}))
const debouncedUpdateVisibleHotels = useMemo( const debouncedUpdateVisibleHotels = useMemo(
() => () =>
@@ -51,7 +54,9 @@ export default function HotelList() {
} }
}, [map, coreLib, debouncedUpdateVisibleHotels]) }, [map, coreLib, debouncedUpdateVisibleHotels])
return ( return isLoading ? (
<HotelListSkeleton />
) : (
<div className={styles.hotelListWrapper}> <div className={styles.hotelListWrapper}>
<div className={styles.header}> <div className={styles.header}>
<Body> <Body>
@@ -60,7 +65,11 @@ export default function HotelList() {
{ count: visibleHotels.length } { count: visibleHotels.length }
)} )}
</Body> </Body>
<HotelFilterAndSort filters={filters} sortItems={sortItems} /> <DestinationFilterAndSort
filters={filters}
sortItems={sortItems}
listType="hotel"
/>
</div> </div>
<ul className={styles.hotelList}> <ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => ( {visibleHotels.map(({ hotel, url }) => (

View File

@@ -0,0 +1,37 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./hotelListItem.module.css"
export default function HotelListItemSkeleton() {
return (
<article className={styles.hotelListItem}>
<div className={styles.imageWrapper}>
<SkeletonShimmer width="100%" height="100%" />
</div>
<div className={styles.content}>
<div className={styles.intro}>
<div className={styles.logo}>
<SkeletonShimmer height="24px" width="100px" />
</div>
<SkeletonShimmer height="35px" width="300px" />
<div className={styles.captions}>
<SkeletonShimmer height="20px" width="130px" />
<SkeletonShimmer height="20px" width="180px" />
</div>
</div>
<ul className={styles.amenityList}>
{Array.from({ length: 5 }).map((_, index) => (
<li className={styles.amenityItem} key={index}>
<SkeletonShimmer height="20px" width="40px" />
</li>
))}
</ul>
<div className={styles.ctaWrapper}>
<SkeletonShimmer height="45px" width="100%" />
</div>
</div>
</article>
)
}

View File

@@ -11,8 +11,25 @@
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x3); padding: var(--Spacing-x2) var(--Spacing-x3);
align-content: start; align-content: flex-start;
justify-items: start; justify-items: flex-start;
}
.imageWrapper {
position: relative;
height: 200px;
}
.tripAdvisor {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Primary-light-Normal);
padding: var(--Spacing-x-quarter) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
} }
.intro { .intro {
@@ -48,7 +65,11 @@
.hotelListItem { .hotelListItem {
width: 360px; width: 360px;
min-height: 150px; min-height: 150px;
grid-template-columns: 1fr 2fr; grid-template-columns: 160px 1fr;
}
.imageWrapper {
height: 100%;
} }
.content { .content {

View File

@@ -4,6 +4,7 @@ import Link from "next/link"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { TripAdvisorIcon } from "@/components/Icons"
import HotelLogo from "@/components/Icons/Logos" import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -29,13 +30,25 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
return ( return (
<article className={styles.hotelListItem}> <article className={styles.hotelListItem}>
<ImageGallery <div className={styles.imageWrapper}>
images={galleryImages} <ImageGallery
title={intl.formatMessage( images={galleryImages}
{ id: "{title} - Image gallery" }, fill
{ title: hotel.name } sizes="(min-width: 768px) 350px, 100vw"
title={intl.formatMessage(
{ id: "{title} - Image gallery" },
{ title: hotel.name }
)}
/>
{hotel.ratings?.tripAdvisor.rating && (
<div className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
<Caption color="burgundy">
{hotel.ratings.tripAdvisor.rating}
</Caption>
</div>
)} )}
/> </div>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.intro}> <div className={styles.intro}>
<div className={styles.logo}> <div className={styles.logo}>

View File

@@ -1,12 +1,12 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import Map from "../../Map" import Map from "../../Map"
import { getCityHeadingText } from "../../utils" import { getHeadingText } from "../../utils"
import HotelList from "./HotelList" import HotelList from "./HotelList"
import styles from "./cityMap.module.css" import styles from "./cityMap.module.css"
@@ -21,7 +21,7 @@ interface CityMapProps {
export default function CityMap({ mapId, apiKey, city }: CityMapProps) { export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
const intl = useIntl() const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useHotelDataStore( const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
(state) => ({ (state) => ({
activeHotels: state.activeHotels, activeHotels: state.activeHotels,
allFilters: state.allFilters, allFilters: state.allFilters,
@@ -37,7 +37,7 @@ export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
textTransform="regular" textTransform="regular"
className={styles.title} className={styles.title}
> >
{getCityHeadingText(intl, city.name, allFilters, activeFilters[0])} {getHeadingText(intl, city.name, allFilters, activeFilters[0])}
</Title> </Title>
<HotelList /> <HotelList />
</Map> </Map>

View File

@@ -23,13 +23,7 @@ import styles from "./destinationCityPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType" import { PageContentTypeEnum } from "@/types/requests/contentType"
interface DestinationCityPageProps { export default async function DestinationCityPage() {
filterFromUrl: string | undefined
}
export default async function DestinationCityPage({
filterFromUrl,
}: DestinationCityPageProps) {
const pageData = await getDestinationCityPage() const pageData = await getDestinationCityPage()
if (!pageData) { if (!pageData) {
@@ -53,10 +47,7 @@ export default async function DestinationCityPage({
return ( return (
<> <>
<Suspense fallback={<DestinationCityPageSkeleton />}> <Suspense fallback={<DestinationCityPageSkeleton />}>
<HotelDataContainer <HotelDataContainer cityIdentifier={cityIdentifier}>
cityIdentifier={cityIdentifier}
filterFromUrl={filterFromUrl}
>
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<header className={styles.header}> <header className={styles.header}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} /> <Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
@@ -67,7 +58,7 @@ export default async function DestinationCityPage({
{blocks && <Blocks blocks={blocks} />} {blocks && <Blocks blocks={blocks} />}
</main> </main>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<SidebarContentWrapper cityName={city.name}> <SidebarContentWrapper location={city.name}>
<Body color="uiTextMediumContrast">{preamble}</Body> <Body color="uiTextMediumContrast">{preamble}</Body>
<ExperienceList experiences={experiences} /> <ExperienceList experiences={experiences} />
{has_sidepeek && ( {has_sidepeek && (

View File

@@ -0,0 +1,23 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import CityListItemSkeleton from "../CityListItem/CityListItemSkeleton"
import styles from "./cityList.module.css"
export default function CityListSkeleton() {
return (
<div className={styles.cityListWrapper}>
<div className={styles.header}>
<SkeletonShimmer height="30px" width="200px" />
<SkeletonShimmer height="30px" width="120px" />
</div>
<ul className={styles.cityList}>
{Array.from({ length: 3 }).map((_, index) => (
<CityListItemSkeleton key={index} />
))}
</ul>
</div>
)
}

View File

@@ -1,31 +1,46 @@
"use client"
import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import CityListItem from "../CityListItem" import CityListItem from "../CityListItem"
import CityListSkeleton from "./CityListSkeleton"
import styles from "./cityList.module.css" import styles from "./cityList.module.css"
import type { DestinationCityListItem } from "@/types/trpc/routers/contentstack/destinationCityPage" export default function CityList() {
const intl = useIntl()
const { filters, sortItems, activeCities, isLoading } =
useDestinationDataStore((state) => ({
filters: state.allFilters,
sortItems: state.sortItems,
activeCities: state.activeCities,
isLoading: state.isLoading,
}))
interface CityListProps { return isLoading ? (
cities: DestinationCityListItem[] <CityListSkeleton />
} ) : (
export default async function CityList({ cities }: CityListProps) {
const intl = await getIntl()
return (
<div className={styles.cityListWrapper}> <div className={styles.cityListWrapper}>
<div className={styles.header}> <div className={styles.header}>
<Body> <Body>
{intl.formatMessage( {intl.formatMessage(
{ id: "{count} destinations" }, { id: "{count} destinations" },
{ count: cities.length } { count: activeCities.length }
)} )}
</Body> </Body>
<DestinationFilterAndSort
filters={filters}
sortItems={sortItems}
listType="city"
/>
</div> </div>
<ul className={styles.cityList}> <ul className={styles.cityList}>
{cities.map((city) => ( {activeCities.map((city) => (
<li key={city.system.uid}> <li key={city.system.uid}>
<CityListItem city={city} /> <CityListItem city={city} />
</li> </li>

View File

@@ -0,0 +1,26 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
import styles from "./cityListItem.module.css"
export default function CityListItemSkeleton() {
return (
<article className={styles.cityListItem}>
<div className={styles.imageWrapper}>
<SkeletonShimmer width="100%" height="100%" />
</div>
<section className={styles.content}>
<SkeletonShimmer height="52px" />
<div className={styles.experienceList}>
<ExperienceListSkeleton />
</div>
<div className={styles.ctaWrapper}>
<SkeletonShimmer height="45px" width="100%" />
</div>
</section>
</article>
)
}

View File

@@ -1,9 +1,11 @@
"use client"
import Link from "next/link" import Link from "next/link"
import { useIntl } from "react-intl"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery" import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
import ExperienceList from "../../../ExperienceList" import ExperienceList from "../../../ExperienceList"
@@ -16,8 +18,8 @@ interface CityListItemProps {
city: DestinationCityListItem city: DestinationCityListItem
} }
export default async function CityListItem({ city }: CityListItemProps) { export default function CityListItem({ city }: CityListItemProps) {
const intl = await getIntl() const intl = useIntl()
const galleryImages = mapImageVaultImagesToGalleryImages(city.images) const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
return ( return (

View File

@@ -1,50 +1,48 @@
import { env } from "@/env/server" "use client"
import {
getDestinationCityPagesByCountry, import { useIntl } from "react-intl"
getHotelsByCountry,
} from "@/lib/trpc/memoizedRequests" import { useDestinationDataStore } from "@/stores/destination-data"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import Map from "../../Map" import Map from "../../Map"
import { getHeadingText } from "../../utils"
import CityList from "./CityList" import CityList from "./CityList"
import styles from "./countryMap.module.css" import styles from "./countryMap.module.css"
import type { Country } from "@/types/enums/country"
interface CountryMapProps { interface CountryMapProps {
country: Country mapId: string
apiKey: string
country: string
} }
export function preload(country: Country) { export default function CountryMap({
void getHotelsByCountry(country) mapId,
void getDestinationCityPagesByCountry(country) apiKey,
} country,
export default async function CountryMap({ country }: CountryMapProps) { }: CountryMapProps) {
const intl = await getIntl() const intl = useIntl()
const [hotels, cities] = await Promise.all([ const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
getHotelsByCountry(country), (state) => ({
getDestinationCityPagesByCountry(country), activeHotels: state.activeHotels,
]) allFilters: state.allFilters,
activeFilters: state.activeFilters,
})
)
return ( return (
<Map <Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="country">
hotels={hotels}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
pageType="country"
>
<Title <Title
level="h2" level="h2"
as="h3" as="h3"
textTransform="regular" textTransform="regular"
className={styles.title} className={styles.title}
> >
{intl.formatMessage({ id: `Destinations in {country}` }, { country })} {getHeadingText(intl, country, allFilters, activeFilters[0])}
</Title> </Title>
<CityList cities={cities} /> <CityList />
</Map> </Map>
) )
} }

View File

@@ -0,0 +1,39 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
import styles from "./destinationCountryPage.module.css"
export default function DestinationCountryPageSkeleton() {
return (
<div className={styles.pageContainer}>
<header className={styles.header}>
<BreadcrumbsSkeleton />
<TopImagesSkeleton />
</header>
<main className={styles.mainContent}>
<CityListingSkeleton />
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapperSkeleton>
<div>
<SkeletonShimmer height="20px" width="90%" />
<SkeletonShimmer height="20px" width="70%" />
<SkeletonShimmer height="20px" width="100%" />
<SkeletonShimmer height="20px" width="40%" />
</div>
<ExperienceListSkeleton />
<div>
<SkeletonShimmer height="200px" width="100%" />
</div>
</SidebarContentWrapperSkeleton>
</aside>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type { IntlShape } from "react-intl"
import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
export function getHeadingText(
intl: IntlShape,
location: string,
allFilters: CategorizedFilters,
filter?: string
) {
if (filter) {
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
return intl.formatMessage(
{ id: "Hotels with {filter} in {location}" },
{ location, filter: facilityFilter.name }
)
} else if (surroudingsFilter) {
return intl.formatMessage(
{ id: "Hotels near {filter} in {location}" },
{ location, filter: surroudingsFilter.name }
)
}
}
return intl.formatMessage({ id: "Hotels in {location}" }, { location })
}

View File

@@ -30,13 +30,6 @@
background-color: var(--Base-Surface-Subtle-Normal); background-color: var(--Base-Surface-Subtle-Normal);
} }
.experienceList {
list-style: none;
display: flex;
gap: var(--Spacing-x1);
flex-wrap: wrap;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.pageContainer { .pageContainer {
max-width: var(--max-width-page); max-width: var(--max-width-page);

View File

@@ -1,22 +1,23 @@
import { Suspense } from "react" import { Suspense } from "react"
import { env } from "@/env/server"
import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests" import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks" import Blocks from "@/components/Blocks"
import Breadcrumbs from "@/components/Breadcrumbs" import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import CityDataContainer, { preload } from "../CityDataContainer"
import CityListing from "../CityListing" import CityListing from "../CityListing"
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
import ExperienceList from "../ExperienceList" import ExperienceList from "../ExperienceList"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek" import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap" import StaticMap from "../StaticMap"
import TopImages from "../TopImages" import TopImages from "../TopImages"
import CountryMap, { preload } from "./CountryMap" import CountryMap from "./CountryMap"
import SidebarContentWrapper from "./SidebarContentWrapper" import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
import styles from "./destinationCountryPage.module.css" import styles from "./destinationCountryPage.module.css"
@@ -33,7 +34,6 @@ export default async function DestinationCountryPage() {
const { const {
blocks, blocks,
images, images,
heading,
preamble, preamble,
experiences, experiences,
has_sidepeek, has_sidepeek,
@@ -46,37 +46,44 @@ export default async function DestinationCountryPage() {
return ( return (
<> <>
<div className={styles.pageContainer}> <Suspense fallback={<DestinationCountryPageSkeleton />}>
<header className={styles.header}> <CityDataContainer country={destination_settings.country}>
<Suspense fallback={<BreadcrumbsSkeleton />}> <div className={styles.pageContainer}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} /> <header className={styles.header}>
</Suspense> <Suspense fallback={<BreadcrumbsSkeleton />}>
<TopImages images={images} destinationName={translatedCountry} /> <Breadcrumbs
</header> variant={PageContentTypeEnum.destinationCityPage}
<main className={styles.mainContent}> />
<Suspense fallback={<CityListingSkeleton />}> </Suspense>
<CityListing country={destination_settings.country} /> <TopImages images={images} destinationName={translatedCountry} />
</Suspense> </header>
{blocks && <Blocks blocks={blocks} />} <main className={styles.mainContent}>
</main> <CityListing />
<aside className={styles.sidebar}> {blocks && <Blocks blocks={blocks} />}
<SidebarContentWrapper> </main>
<Title level="h2">{heading}</Title> <aside className={styles.sidebar}>
<Body color="uiTextMediumContrast">{preamble}</Body> <SidebarContentWrapper location={translatedCountry}>
<ExperienceList experiences={experiences} /> <Body color="uiTextMediumContrast">{preamble}</Body>
{has_sidepeek && ( <ExperienceList experiences={experiences} />
<DestinationPageSidePeek {has_sidepeek && (
buttonText={sidepeek_button_text} <DestinationPageSidePeek
sidePeekContent={sidepeek_content} buttonText={sidepeek_button_text}
/> sidePeekContent={sidepeek_content}
)} />
)}
<StaticMap country={destination_settings.country} /> <StaticMap country={destination_settings.country} />
</SidebarContentWrapper> </SidebarContentWrapper>
</aside> </aside>
</div> </div>
<CountryMap country={destination_settings.country} /> <CountryMap
<TrackingSDK pageData={tracking} /> country={translatedCountry}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
/>
</CityDataContainer>
</Suspense>
<TrackingSDK pageData={tracking} />
</> </>
) )
} }

View File

@@ -1,5 +1,8 @@
"use client"
import { useIntl } from "react-intl"
import Chip from "@/components/TempDesignSystem/Chip" import Chip from "@/components/TempDesignSystem/Chip"
import { getIntl } from "@/i18n"
import { mapExperiencesToListData } from "./utils" import { mapExperiencesToListData } from "./utils"
@@ -9,10 +12,8 @@ interface ExperienceListProps {
experiences: string[] experiences: string[]
} }
export default async function ExperienceList({ export default function ExperienceList({ experiences }: ExperienceListProps) {
experiences, const intl = useIntl()
}: ExperienceListProps) {
const intl = await getIntl()
const experienceList = mapExperiencesToListData(experiences, intl) const experienceList = mapExperiencesToListData(experiences, intl)
return ( return (

View File

@@ -1,14 +1,13 @@
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests" import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import HotelDataProvider from "@/providers/HotelDataProvider" import DestinationDataProvider from "@/providers/DestinationDataProvider"
import type { SortItem } from "@/types/components/hotelFilterAndSort" import type { SortItem } from "@/types/components/destinationFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort" import { SortOption } from "@/types/enums/destinationFilterAndSort"
interface HotelDataContainerProps extends React.PropsWithChildren { interface HotelDataContainerProps extends React.PropsWithChildren {
cityIdentifier: string cityIdentifier: string
filterFromUrl?: string
} }
export function preload(cityIdentifier: string) { export function preload(cityIdentifier: string) {
@@ -17,7 +16,6 @@ export function preload(cityIdentifier: string) {
export default async function HotelDataContainer({ export default async function HotelDataContainer({
cityIdentifier, cityIdentifier,
filterFromUrl,
children, children,
}: HotelDataContainerProps) { }: HotelDataContainerProps) {
const intl = await getIntl() const intl = await getIntl()
@@ -27,6 +25,7 @@ export default async function HotelDataContainer({
{ {
label: intl.formatMessage({ id: "Distance to city center" }), label: intl.formatMessage({ id: "Distance to city center" }),
value: SortOption.Distance, value: SortOption.Distance,
isDefault: true,
}, },
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name }, { label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
{ {
@@ -36,12 +35,8 @@ export default async function HotelDataContainer({
] ]
return ( return (
<HotelDataProvider <DestinationDataProvider allHotels={hotels} sortItems={sortItems}>
allHotels={hotels}
filterFromUrl={filterFromUrl}
sortItems={sortItems}
>
{children} {children}
</HotelDataProvider> </DestinationDataProvider>
) )
} }

View File

@@ -15,6 +15,7 @@
.imageWrapper { .imageWrapper {
position: relative; position: relative;
height: 200px;
} }
.tripAdvisor { .tripAdvisor {
@@ -64,6 +65,10 @@
grid-template-columns: minmax(250px, 350px) auto; grid-template-columns: minmax(250px, 350px) auto;
} }
.imageWrapper {
height: 100%;
}
.ctaWrapper { .ctaWrapper {
justify-self: end; justify-self: end;
} }

View File

@@ -37,6 +37,8 @@ export default function HotelListingItem({
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<ImageGallery <ImageGallery
images={galleryImages} images={galleryImages}
fill
sizes="(min-width: 768px) 350px, 100vw"
title={intl.formatMessage( title={intl.formatMessage(
{ id: "{title} - Image gallery" }, { id: "{title} - Image gallery" },
{ title: hotel.name } { title: hotel.name }

View File

@@ -11,6 +11,7 @@ export default function HotelListingSkeleton() {
<section className={styles.container}> <section className={styles.container}>
<div className={styles.listHeader}> <div className={styles.listHeader}>
<SkeletonShimmer height="30px" width="300px" /> <SkeletonShimmer height="30px" width="300px" />
<SkeletonShimmer height="30px" width="100px" />
</div> </div>
<ul className={styles.hotelList}> <ul className={styles.hotelList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (

View File

@@ -1,52 +1,39 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import HotelFilterAndSort from "@/components/HotelFilterAndSort" import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton" import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton" import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useHash from "@/hooks/useHash"
import { useScrollToTop } from "@/hooks/useScrollToTop" import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelListingItem from "./HotelListingItem" import HotelListingItem from "./HotelListingItem"
import HotelListingSkeleton from "./HotelListingSkeleton"
import styles from "./hotelListing.module.css" import styles from "./hotelListing.module.css"
export default function HotelListing() { export default function HotelListing() {
const intl = useIntl() const intl = useIntl()
const hash = useHash() const scrollRef = useRef<HTMLElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300, threshold: 300,
elementRef: scrollRef, elementRef: scrollRef,
}) })
const { const { activeHotels, filters, sortItems, isLoading } =
activeHotels, useDestinationDataStore((state) => ({
filters, activeHotels: state.activeHotels,
sortItems, filters: state.allFilters,
initialHashFilterLoaded, sortItems: state.sortItems,
loadInitialHashFilter, isLoading: state.isLoading,
} = useHotelDataStore((state) => ({ }))
activeHotels: state.activeHotels,
filters: state.allFilters,
sortItems: state.sortItems,
initialHashFilterLoaded: state.initialHashFilterLoaded,
loadInitialHashFilter: state.actions.loadInitialHashFilter,
}))
const [allHotelsVisible, setAllHotelsVisible] = useState( const [allHotelsVisible, setAllHotelsVisible] = useState(
activeHotels.length <= 5 activeHotels.length <= 5
) )
useEffect(() => {
if (hash !== undefined && !initialHashFilterLoaded) {
loadInitialHashFilter(hash)
}
}, [hash, loadInitialHashFilter, initialHashFilterLoaded])
function handleShowMore() { function handleShowMore() {
if (scrollRef.current && allHotelsVisible) { if (scrollRef.current && allHotelsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" }) scrollRef.current.scrollIntoView({ behavior: "smooth" })
@@ -54,7 +41,9 @@ export default function HotelListing() {
setAllHotelsVisible((state) => !state) setAllHotelsVisible((state) => !state)
} }
return ( return isLoading ? (
<HotelListingSkeleton />
) : (
<section className={styles.container} ref={scrollRef}> <section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}> <div className={styles.listHeader}>
<Subtitle type="two"> <Subtitle type="two">
@@ -65,7 +54,11 @@ export default function HotelListing() {
{ count: activeHotels.length } { count: activeHotels.length }
)} )}
</Subtitle> </Subtitle>
<HotelFilterAndSort filters={filters} sortItems={sortItems} /> <DestinationFilterAndSort
filters={filters}
sortItems={sortItems}
listType="hotel"
/>
</div> </div>
<ul <ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`} className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}

View File

@@ -13,7 +13,7 @@
.sidebar { .sidebar {
width: 100%; width: 100%;
max-width: 400px; max-width: 500px;
background-color: var(--Base-Surface-Primary-Normal); background-color: var(--Base-Surface-Primary-Normal);
overflow-y: auto; overflow-y: auto;
padding: var(--Spacing-x4); padding: var(--Spacing-x4);

View File

@@ -3,22 +3,22 @@
import { useRef } from "react" import { useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import { StickyElementNameEnum } from "@/stores/sticky-position" import { StickyElementNameEnum } from "@/stores/sticky-position"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import useStickyPosition from "@/hooks/useStickyPosition" import useStickyPosition from "@/hooks/useStickyPosition"
import { getCityHeadingText } from "../utils" import { getHeadingText } from "../utils"
import styles from "./sidebarContentWrapper.module.css" import styles from "./sidebarContentWrapper.module.css"
interface SidebarContentWrapperProps extends React.PropsWithChildren { interface SidebarContentWrapperProps extends React.PropsWithChildren {
cityName?: string location: string
} }
export default function SidebarContentWrapper({ export default function SidebarContentWrapper({
cityName, location,
children, children,
}: SidebarContentWrapperProps) { }: SidebarContentWrapperProps) {
const intl = useIntl() const intl = useIntl()
@@ -27,17 +27,16 @@ export default function SidebarContentWrapper({
ref: sidebarRef, ref: sidebarRef,
name: StickyElementNameEnum.DESTINATION_SIDEBAR, name: StickyElementNameEnum.DESTINATION_SIDEBAR,
}) })
const { activeFilters, allFilters } = useHotelDataStore((state) => ({ const { activeFilters, allFilters } = useDestinationDataStore((state) => ({
activeFilters: state.activeFilters, activeFilters: state.activeFilters,
allFilters: state.allFilters, allFilters: state.allFilters,
})) }))
const headingText = cityName
? getCityHeadingText(intl, cityName, allFilters, activeFilters[0])
: null
return ( return (
<div ref={sidebarRef} className={styles.sidebarContent}> <div ref={sidebarRef} className={styles.sidebarContent}>
{headingText && <Title level="h2">{headingText}</Title>} <Title level="h2">
{getHeadingText(intl, location, allFilters, activeFilters[0])}
</Title>
{children} {children}
</div> </div>
) )

View File

@@ -1,10 +1,10 @@
import type { IntlShape } from "react-intl" import type { IntlShape } from "react-intl"
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort" import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
export function getCityHeadingText( export function getHeadingText(
intl: IntlShape, intl: IntlShape,
cityName: string, location: string,
allFilters: CategorizedFilters, allFilters: CategorizedFilters,
filter?: string filter?: string
) { ) {
@@ -18,15 +18,15 @@ export function getCityHeadingText(
if (facilityFilter) { if (facilityFilter) {
return intl.formatMessage( return intl.formatMessage(
{ id: "Hotels with {filter} in {cityName}" }, { id: "Hotels with {filter} in {location}" },
{ cityName, filter: facilityFilter.name } { location, filter: facilityFilter.name }
) )
} else if (surroudingsFilter) { } else if (surroudingsFilter) {
return intl.formatMessage( return intl.formatMessage(
{ id: "Hotels near {filter} in {cityName}" }, { id: "Hotels near {filter} in {location}" },
{ cityName, filter: surroudingsFilter.name } { location, filter: surroudingsFilter.name }
) )
} }
} }
return intl.formatMessage({ id: "Hotels in {city}" }, { city: cityName }) return intl.formatMessage({ id: "Hotels in {location}" }, { location })
} }

View File

@@ -2,7 +2,7 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
@@ -11,7 +11,7 @@ import Checkbox from "./Checkbox"
import styles from "./filter.module.css" import styles from "./filter.module.css"
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort" import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
interface FilterProps { interface FilterProps {
filters: CategorizedFilters filters: CategorizedFilters
@@ -20,7 +20,7 @@ interface FilterProps {
export default function Filter({ filters }: FilterProps) { export default function Filter({ filters }: FilterProps) {
const intl = useIntl() const intl = useIntl()
const { facilityFilters, surroundingsFilters } = filters const { facilityFilters, surroundingsFilters } = filters
const { pendingFilters, togglePendingFilter } = useHotelDataStore( const { pendingFilters, togglePendingFilter } = useDestinationDataStore(
(state) => ({ (state) => ({
pendingFilters: state.pendingFilters, pendingFilters: state.pendingFilters,
togglePendingFilter: state.actions.togglePendingFilter, togglePendingFilter: state.actions.togglePendingFilter,

View File

@@ -2,12 +2,12 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import Select from "@/components/TempDesignSystem/Select" import Select from "@/components/TempDesignSystem/Select"
import type { SortItem } from "@/types/components/hotelFilterAndSort" import type { SortItem } from "@/types/components/destinationFilterAndSort"
import type { SortOption } from "@/types/enums/hotelFilterAndSort" import type { SortOption } from "@/types/enums/destinationFilterAndSort"
interface SortProps { interface SortProps {
sortItems: SortItem[] sortItems: SortItem[]
@@ -15,7 +15,7 @@ interface SortProps {
export default function Sort({ sortItems }: SortProps) { export default function Sort({ sortItems }: SortProps) {
const intl = useIntl() const intl = useIntl()
const { pendingSort, setPendingSort } = useHotelDataStore((state) => ({ const { pendingSort, setPendingSort } = useDestinationDataStore((state) => ({
pendingSort: state.pendingSort, pendingSort: state.pendingSort,
setPendingSort: state.actions.setPendingSort, setPendingSort: state.actions.setPendingSort,
})) }))

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useRouter } from "next/navigation"
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -8,7 +9,7 @@ import {
} from "react-aria-components" } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data" import { useDestinationDataStore } from "@/stores/destination-data"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons" import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -19,39 +20,75 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Filter from "./Filter" import Filter from "./Filter"
import Sort from "./Sort" import Sort from "./Sort"
import styles from "./hotelFilterAndSort.module.css" import styles from "./destinationFilterAndSort.module.css"
import type { import type {
CategorizedFilters, CategorizedFilters,
SortItem, SortItem,
} from "@/types/components/hotelFilterAndSort" } from "@/types/components/destinationFilterAndSort"
interface HotelFilterAndSortProps { interface HotelFilterAndSortProps {
filters: CategorizedFilters filters: CategorizedFilters
sortItems: SortItem[] sortItems: SortItem[]
listType: "city" | "hotel"
} }
export default function HotelFilterAndSort({ export default function DestinationFilterAndSort({
filters, filters,
sortItems, sortItems,
listType,
}: HotelFilterAndSortProps) { }: HotelFilterAndSortProps) {
const intl = useIntl() const intl = useIntl()
const router = useRouter()
const { const {
pendingFilters,
pendingSort,
defaultSort,
basePath,
pendingCount, pendingCount,
activeFilters, activeFilters,
clearPendingFilters, clearPendingFilters,
resetPendingValues, resetPendingValues,
submitFiltersAndSort, setIsLoading,
} = useHotelDataStore((state) => ({ } = useDestinationDataStore((state) => ({
pendingCount: state.pendingCount, pendingFilters: state.pendingFilters,
pendingSort: state.pendingSort,
basePath: state.basePathnameWithoutFilters,
defaultSort: state.defaultSort,
pendingCount:
listType === "city" ? state.pendingCityCount : state.pendingHotelCount,
activeFilters: state.activeFilters, activeFilters: state.activeFilters,
clearPendingFilters: state.actions.clearPendingFilters, clearPendingFilters: state.actions.clearPendingFilters,
resetPendingValues: state.actions.resetPendingValues, resetPendingValues: state.actions.resetPendingValues,
submitFiltersAndSort: state.actions.submitFiltersAndSort, setIsLoading: state.actions.setIsLoading,
})) }))
function submitAndClose(close: () => void) { function submitAndClose(close: () => void) {
submitFiltersAndSort() 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)
}
const [firstFilter, ...remainingFilters] = filters
parsedUrl.pathname = basePath
if (firstFilter) {
parsedUrl.pathname += `/${firstFilter}`
}
if (remainingFilters.length > 0) {
parsedUrl.hash = `#${remainingFilters.join("&")}`
} else {
parsedUrl.hash = ""
}
router.push(parsedUrl.toString(), { scroll: false })
close() close()
} }

View File

@@ -0,0 +1,6 @@
import { createContext } from "react"
import type { DestinationDataStore } from "@/types/contexts/destination-data"
export const DestinationDataContext =
createContext<DestinationDataStore | null>(null)

View File

@@ -1,5 +0,0 @@
import { createContext } from "react"
import type { HotelDataStore } from "@/types/contexts/hotel-data"
export const HotelDataContext = createContext<HotelDataStore | null>(null)

View File

@@ -287,8 +287,9 @@
"Hotel surroundings": "Hotel omgivelser", "Hotel surroundings": "Hotel omgivelser",
"Hotels": "Hoteller", "Hotels": "Hoteller",
"Hotels & Destinations": "Hoteller & destinationer", "Hotels & Destinations": "Hoteller & destinationer",
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}", "Hotels in {location}": "Hoteller i {location}",
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}", "Hotels near {filter} in {location}": "Hoteller nær {filter} i {location}",
"Hotels with {filter} in {location}": "Hoteller med {filter} i {location}",
"Hours": "Tider", "Hours": "Tider",
"How do you want to sleep?": "Hvordan vil du sove?", "How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker", "How it works": "Hvordan det virker",
@@ -515,6 +516,7 @@
"Read more about the hotel": "Læs mere om hotellet", "Read more about the hotel": "Læs mere om hotellet",
"Read more about wellness & exercise": "Læs mere om wellness & motion", "Read more about wellness & exercise": "Læs mere om wellness & motion",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Anbefalet",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -288,8 +288,9 @@
"Hotel surroundings": "Umgebung des Hotels", "Hotel surroundings": "Umgebung des Hotels",
"Hotels": "Hotels", "Hotels": "Hotels",
"Hotels & Destinations": "Hotels & Reiseziele", "Hotels & Destinations": "Hotels & Reiseziele",
"Hotels near {filter} in {cityName}": "Hotels in der Nähe von {filter} in {cityName}", "Hotels in {location}": "Hotels in {location}",
"Hotels with {filter} in {cityName}": "Hotels mit {filter} in {cityName}", "Hotels near {filter} in {location}": "Hotels in der Nähe von {filter} in {location}",
"Hotels with {filter} in {location}": "Hotels mit {filter} in {location}",
"Hours": "Zeiten", "Hours": "Zeiten",
"How do you want to sleep?": "Wie möchtest du schlafen?", "How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert", "How it works": "Wie es funktioniert",
@@ -517,6 +518,7 @@
"Read more about the hotel": "Lesen Sie mehr über das Hotel", "Read more about the hotel": "Lesen Sie mehr über das Hotel",
"Read more about wellness & exercise": "Lesen Sie mehr über Wellness & Bewegung", "Read more about wellness & exercise": "Lesen Sie mehr über Wellness & Bewegung",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Empfohlen",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -289,9 +289,9 @@
"Hotel surroundings": "Hotel surroundings", "Hotel surroundings": "Hotel surroundings",
"Hotels": "Hotels", "Hotels": "Hotels",
"Hotels & Destinations": "Hotels & Destinations", "Hotels & Destinations": "Hotels & Destinations",
"Hotels in {city}": "Hotels in {city}", "Hotels in {location}": "Hotels in {location}",
"Hotels near {filter} in {cityName}": "Hotels near {filter} in {cityName}", "Hotels near {filter} in {location}": "Hotels near {filter} in {location}",
"Hotels with {filter} in {cityName}": "Hotels with {filter} in {cityName}", "Hotels with {filter} in {location}": "Hotels with {filter} in {location}",
"Hours": "Hours", "Hours": "Hours",
"How do you want to sleep?": "How do you want to sleep?", "How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works", "How it works": "How it works",
@@ -519,6 +519,7 @@
"Read more about the hotel": "Read more about the hotel", "Read more about the hotel": "Read more about the hotel",
"Read more about wellness & exercise": "Read more about wellness & exercise", "Read more about wellness & exercise": "Read more about wellness & exercise",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Recommended",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -287,8 +287,9 @@
"Hotel surroundings": "Hotellin ympäristö", "Hotel surroundings": "Hotellin ympäristö",
"Hotels": "Hotellit", "Hotels": "Hotellit",
"Hotels & Destinations": "Hotellit ja Kohteet", "Hotels & Destinations": "Hotellit ja Kohteet",
"Hotels near {filter} in {cityName}": "Hotellit lähellä {filter} kaupungissa {cityName}", "Hotels in {location}": "Hotellit kohteessa {location}",
"Hotels with {filter} in {cityName}": "Hotellit, joissa on {filter} kaupungissa {cityName}", "Hotels near {filter} in {location}": "Hotellit lähellä {filter} kaupungissa {location}",
"Hotels with {filter} in {location}": "Hotellit, joissa on {filter} kaupungissa {location}",
"Hours": "Ajat", "Hours": "Ajat",
"How do you want to sleep?": "Kuinka haluat nukkua?", "How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii", "How it works": "Kuinka se toimii",
@@ -516,6 +517,7 @@
"Read more about the hotel": "Lue lisää hotellista", "Read more about the hotel": "Lue lisää hotellista",
"Read more about wellness & exercise": "Lue lisää hyvinvoinnista ja liikunnasta", "Read more about wellness & exercise": "Lue lisää hyvinvoinnista ja liikunnasta",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Suositeltu",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -286,8 +286,9 @@
"Hotel surroundings": "Hotellomgivelser", "Hotel surroundings": "Hotellomgivelser",
"Hotels": "Hoteller", "Hotels": "Hoteller",
"Hotels & Destinations": "Hoteller og Destinasjoner", "Hotels & Destinations": "Hoteller og Destinasjoner",
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}", "Hotels in {location}": "Hoteller i {location}",
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}", "Hotels near {filter} in {location}": "Hoteller nær {filter} i {location}",
"Hotels with {filter} in {location}": "Hoteller med {filter} i {location}",
"Hours": "Tider", "Hours": "Tider",
"How do you want to sleep?": "Hvordan vil du sove?", "How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer", "How it works": "Hvordan det fungerer",
@@ -513,6 +514,7 @@
"Read more about the hotel": "Les mer om hotellet", "Read more about the hotel": "Les mer om hotellet",
"Read more about wellness & exercise": "Les mer om velvære og trening", "Read more about wellness & exercise": "Les mer om velvære og trening",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Anbefalt",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -286,8 +286,9 @@
"Hotel surroundings": "Hotellomgivning", "Hotel surroundings": "Hotellomgivning",
"Hotels": "Hotell", "Hotels": "Hotell",
"Hotels & Destinations": "Hotell & destinationer", "Hotels & Destinations": "Hotell & destinationer",
"Hotels near {filter} in {cityName}": "Hotell nära {filter} i {cityName}", "Hotels in {location}": "Hotell i {location}",
"Hotels with {filter} in {cityName}": "Hotell med {filter} i {cityName}", "Hotels near {filter} in {location}": "Hotell nära {filter} i {location}",
"Hotels with {filter} in {location}": "Hotell med {filter} i {location}",
"Hours": "Tider", "Hours": "Tider",
"How do you want to sleep?": "Hur vill du sova?", "How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar", "How it works": "Hur det fungerar",
@@ -513,6 +514,7 @@
"Read more about the hotel": "Läs mer om hotellet", "Read more about the hotel": "Läs mer om hotellet",
"Read more about wellness & exercise": "Läs mer om friskvård & träning", "Read more about wellness & exercise": "Läs mer om friskvård & träning",
"Rebooking": "Rebooking", "Rebooking": "Rebooking",
"Recommended": "Rekommenderad",
"Redeem benefit": "Redeem benefit", "Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:", "Redeemed & valid through:": "Redeemed & valid through:",
"Redirecting you to SAS": "Redirecting you to SAS", "Redirecting you to SAS": "Redirecting you to SAS",

View File

@@ -16,6 +16,15 @@ query GetDestinationCityListData($locale: String!, $cityIdentifier: String!) {
) { ) {
items { items {
heading heading
destination_settings {
city_denmark
city_finland
city_germany
city_norway
city_poland
city_sweden
}
sort_order
preamble preamble
images { images {
image image

View File

@@ -11,6 +11,9 @@ query GetDestinationCountryPageMetadata($locale: String!, $uid: String!) {
...Metadata ...Metadata
} }
} }
destination_settings {
country
}
system { system {
...System ...System
} }

View File

@@ -48,6 +48,7 @@ export const middleware: NextMiddleware = async (request) => {
searchParams.set("subpage", subpage) searchParams.set("subpage", subpage)
break break
case PageContentTypeEnum.destinationCityPage: case PageContentTypeEnum.destinationCityPage:
case PageContentTypeEnum.destinationCountryPage:
// E.g. Active filters inside destination pages to filter hotels. // E.g. Active filters inside destination pages to filter hotels.
searchParams.set("filterFromUrl", subpage) searchParams.set("filterFromUrl", subpage)
break break

View File

@@ -0,0 +1,39 @@
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}</>
}

View File

@@ -0,0 +1,39 @@
"use client"
import { usePathname } 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,
sortItems,
children,
}: DestinationDataProviderProps) {
const storeRef = useRef<DestinationDataStore>()
const pathname = usePathname()
if (!storeRef.current) {
storeRef.current = createDestinationDataStore({
allCities,
allHotels,
pathname,
sortItems,
})
}
return (
<DestinationDataContext.Provider value={storeRef.current}>
<DestinationDataProviderContent>
{children}
</DestinationDataProviderContent>
</DestinationDataContext.Provider>
)
}

View File

@@ -1,65 +0,0 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useRef } from "react"
import { createHotelDataStore } from "@/stores/hotel-data"
import { DEFAULT_SORT } from "@/stores/hotel-data/helper"
import { HotelDataContext } from "@/contexts/HotelData"
import type { HotelDataStore } from "@/types/contexts/hotel-data"
import type { HotelDataProviderProps } from "@/types/providers/hotel-data"
import type { SubmitCallbackData } from "@/types/stores/hotel-data"
export default function HotelDataProvider({
allHotels,
filterFromUrl,
sortItems,
children,
}: HotelDataProviderProps) {
const storeRef = useRef<HotelDataStore>()
const searchParams = useSearchParams()
const pathname = usePathname()
const router = useRouter()
function submitCallbackFn({ sort, filters, basePath }: SubmitCallbackData) {
const parsedUrl = new URL(window.location.href)
const searchParams = parsedUrl.searchParams
if (sort === DEFAULT_SORT && searchParams.has("sort")) {
searchParams.delete("sort")
} else if (sort !== DEFAULT_SORT) {
searchParams.set("sort", sort)
}
const [firstFilter, ...remainingFilters] = filters
parsedUrl.pathname = basePath
if (firstFilter) {
parsedUrl.pathname += `/${firstFilter}`
}
if (remainingFilters.length > 0) {
parsedUrl.hash = `#${remainingFilters.join("&")}`
} else {
parsedUrl.hash = ""
}
router.push(parsedUrl.toString(), { scroll: false })
}
if (!storeRef.current) {
storeRef.current = createHotelDataStore({
allHotels,
pathname,
searchParams,
filterFromUrl,
sortItems,
submitCallbackFn,
})
}
return (
<HotelDataContext.Provider value={storeRef.current}>
{children}
</HotelDataContext.Provider>
)
}

View File

@@ -31,6 +31,37 @@ export const destinationCityListDataSchema = z
z z
.object({ .object({
heading: z.string(), heading: z.string(),
destination_settings: z
.object({
city_denmark: z.string().optional().nullable(),
city_finland: z.string().optional().nullable(),
city_germany: z.string().optional().nullable(),
city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(),
})
.transform(
({
city_denmark,
city_finland,
city_germany,
city_norway,
city_poland,
city_sweden,
}) => {
const cities = [
city_denmark,
city_finland,
city_germany,
city_poland,
city_norway,
city_sweden,
].filter((city): city is string => Boolean(city))
return { city: cities[0] }
}
),
sort_order: z.number().nullable(),
preamble: z.string(), preamble: z.string(),
experiences: z experiences: z
.object({ .object({
@@ -81,6 +112,37 @@ export const blocksSchema = z.discriminatedUnion("__typename", [
destinationCityPageContent, destinationCityPageContent,
]) ])
export const destinationCityPageDestinationSettingsSchema = z
.object({
city_denmark: z.string().optional().nullable(),
city_finland: z.string().optional().nullable(),
city_germany: z.string().optional().nullable(),
city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(),
})
.transform(
({
city_denmark,
city_finland,
city_germany,
city_norway,
city_poland,
city_sweden,
}) => {
const cities = [
city_denmark,
city_finland,
city_germany,
city_poland,
city_norway,
city_sweden,
].filter((city): city is string => Boolean(city))
return { city: cities[0] }
}
)
export const destinationCityPageSchema = z export const destinationCityPageSchema = z
.object({ .object({
destination_city_page: z.object({ destination_city_page: z.object({

View File

@@ -2,4 +2,5 @@ import { z } from "zod"
export const getMetadataInput = z.object({ export const getMetadataInput = z.object({
subpage: z.string().optional(), subpage: z.string().optional(),
filterFromUrl: z.string().optional(),
}) })

View File

@@ -7,6 +7,7 @@ import { getDescription, getImage, getTitle } from "./utils"
import type { Metadata } from "next" import type { Metadata } from "next"
import { Country } from "@/types/enums/country"
import { RTETypeEnum } from "@/types/rte/enums" import { RTETypeEnum } from "@/types/rte/enums"
const metaDataJsonSchema = z.object({ const metaDataJsonSchema = z.object({
@@ -70,6 +71,7 @@ export const rawMetadataSchema = z.object({
city_poland: z.string().optional().nullable(), city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(), city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(), city_sweden: z.string().optional().nullable(),
country: z.nativeEnum(Country).optional().nullable(),
}) })
.optional() .optional()
.nullable(), .nullable(),
@@ -89,9 +91,9 @@ export const rawMetadataSchema = z.object({
.pick({ name: true, address: true, hotelContent: true, gallery: true }) .pick({ name: true, address: true, hotelContent: true, gallery: true })
.optional() .optional()
.nullable(), .nullable(),
cityName: z.string().optional().nullable(), location: z.string().optional().nullable(),
cityFilter: z.string().optional().nullable(), filter: z.string().optional().nullable(),
cityFilterType: z.enum(["facility", "surroundings"]).optional().nullable(), filterType: z.enum(["facility", "surroundings"]).optional().nullable(),
system: systemSchema, system: systemSchema,
}) })

View File

@@ -18,7 +18,7 @@ import { generateTag } from "@/utils/generateTag"
import { getHotel } from "../../hotels/query" import { getHotel } from "../../hotels/query"
import { getMetadataInput } from "./input" import { getMetadataInput } from "./input"
import { metadataSchema } from "./output" import { metadataSchema } from "./output"
import { affix, getCityData } from "./utils" import { affix, getCityData, getCountryData } from "./utils"
import { PageContentTypeEnum } from "@/types/requests/contentType" import { PageContentTypeEnum } from "@/types/requests/contentType"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata" import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
@@ -153,9 +153,16 @@ export const metadataQueryRouter = router({
const destinationCountryPageResponse = await fetchMetadata<{ const destinationCountryPageResponse = await fetchMetadata<{
destination_country_page: RawMetadataSchema destination_country_page: RawMetadataSchema
}>(GetDestinationCountryPageMetadata, variables) }>(GetDestinationCountryPageMetadata, variables)
return getTransformedMetadata( const countryData = await getCountryData(
destinationCountryPageResponse.destination_country_page destinationCountryPageResponse.destination_country_page,
input,
ctx.serviceToken,
ctx.lang
) )
return getTransformedMetadata({
...destinationCountryPageResponse.destination_country_page,
...countryData,
})
case PageContentTypeEnum.destinationCityPage: case PageContentTypeEnum.destinationCityPage:
const destinationCityPageResponse = await fetchMetadata<{ const destinationCityPageResponse = await fetchMetadata<{
destination_city_page: RawMetadataSchema destination_city_page: RawMetadataSchema

View File

@@ -1,19 +1,23 @@
import { getFiltersFromHotels } from "@/stores/hotel-data/helper" import { ApiLang, type Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { getFiltersFromHotels } from "@/stores/destination-data/helper"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { import {
getCityByCityIdentifier, getCityByCityIdentifier,
getHotelIdsByCityIdentifier, getHotelIdsByCityIdentifier,
getHotelIdsByCountry,
getHotelsByHotelIds, getHotelsByHotelIds,
} from "../../hotels/utils" } from "../../hotels/utils"
import { ApiCountry } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { RTETypeEnum } from "@/types/rte/enums" import { RTETypeEnum } from "@/types/rte/enums"
import type { import type {
MetadataInputSchema, MetadataInputSchema,
RawMetadataSchema, RawMetadataSchema,
} from "@/types/trpc/routers/contentstack/metadata" } from "@/types/trpc/routers/contentstack/metadata"
import type { Lang } from "@/constants/languages"
export const affix = "metadata" export const affix = "metadata"
@@ -87,25 +91,26 @@ export async function getTitle(data: RawMetadataSchema) {
} }
) )
} }
if (data.system.content_type_uid === "destination_city_page") { if (
if (data.cityName) { data.system.content_type_uid === "destination_city_page" ||
if (data.cityFilter) { data.system.content_type_uid === "destination_country_page"
if (data.cityFilterType === "facility") { ) {
const { location, filter, filterType } = data
if (location) {
if (filter) {
if (filterType === "facility") {
return intl.formatMessage( return intl.formatMessage(
{ id: "Hotels with {filter} in {cityName}" }, { id: "Hotels with {filter} in {location}" },
{ cityName: data.cityName, filter: data.cityFilter } { location, filter }
) )
} else if (data.cityFilterType === "surroundings") { } else if (filterType === "surroundings") {
return intl.formatMessage( return intl.formatMessage(
{ id: "Hotels near {filter} in {cityName}" }, { id: "Hotels near {filter} in {location}" },
{ cityName: data.cityName, filter: data.cityFilter } { location, filter }
) )
} }
} }
return intl.formatMessage( return intl.formatMessage({ id: "Hotels in {location}" }, { location })
{ id: "Hotels in {city}" },
{ city: data.cityName }
)
} }
} }
if (data.web?.breadcrumbs?.title) { if (data.web?.breadcrumbs?.title) {
@@ -190,10 +195,7 @@ export async function getCityData(
lang: Lang lang: Lang
) { ) {
const destinationSettings = data.destination_settings const destinationSettings = data.destination_settings
const cityFilter = input.subpage const filter = input.filterFromUrl
let cityIdentifier
let cityData
let filterType
if (destinationSettings) { if (destinationSettings) {
const { const {
@@ -213,23 +215,27 @@ export async function getCityData(
city_sweden, city_sweden,
].filter((city): city is string => Boolean(city)) ].filter((city): city is string => Boolean(city))
cityIdentifier = cities[0] const cityIdentifier = cities[0]
if (cityIdentifier) { if (cityIdentifier) {
cityData = await getCityByCityIdentifier(cityIdentifier, serviceToken) const cityData = await getCityByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotelIds = await getHotelIdsByCityIdentifier( const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier, cityIdentifier,
serviceToken serviceToken
) )
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken) const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
let filterType
if (cityFilter) { if (filter) {
const allFilters = getFiltersFromHotels(hotels) const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find( const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === cityFilter (f) => f.slug === filter
) )
const surroudingsFilter = allFilters.surroundingsFilters.find( const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === cityFilter (f) => f.slug === filter
) )
if (facilityFilter) { if (facilityFilter) {
@@ -238,8 +244,65 @@ export async function getCityData(
filterType = "surroundings" filterType = "surroundings"
} }
} }
return { location: cityData?.name, filter, filterType }
} }
return { cityName: cityData?.name, cityFilter, cityFilterType: filterType } }
return null
}
export async function getCountryData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const country = data.destination_settings?.country
const filter = input.filterFromUrl
if (country) {
const translatedCountry = ApiCountry[lang][country]
let filterType
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIdsParams = new URLSearchParams({
language: ApiLang.En,
country,
})
const hotelIds = await getHotelIdsByCountry(
country,
options,
hotelIdsParams
)
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
return { location: translatedCountry, filter, filterType }
} }
return null return null
} }

View File

@@ -2,13 +2,13 @@ import type {
CategorizedFilters, CategorizedFilters,
Filter, Filter,
SortItem, SortItem,
} from "@/types/components/hotelFilterAndSort" } from "@/types/components/destinationFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort" import { SortOption } from "@/types/enums/destinationFilterAndSort"
import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationCityListItem } from "@/types/trpc/routers/contentstack/destinationCityPage"
export const SORTING_STRATAGIES: Record< const HOTEL_SORTING_STRATEGIES: Partial<
SortOption, Record<SortOption, (a: HotelDataWithUrl, b: HotelDataWithUrl) => number>
(a: HotelDataWithUrl, b: HotelDataWithUrl) => number
> = { > = {
[SortOption.Name]: function (a, b) { [SortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name) return a.hotel.name.localeCompare(b.hotel.name)
@@ -24,6 +24,29 @@ export const SORTING_STRATAGIES: Record<
}, },
} }
const CITY_SORTING_STRATEGIES: Partial<
Record<
SortOption,
(a: DestinationCityListItem, b: DestinationCityListItem) => number
>
> = {
[SortOption.Name]: function (a, b) {
return a.cityName.localeCompare(b.cityName)
},
[SortOption.Recommended]: function (a, b) {
if (a.sort_order === null && b.sort_order === null) {
return a.cityName.localeCompare(b.cityName)
}
if (a.sort_order === null) {
return 1
}
if (b.sort_order === null) {
return -1
}
return b.sort_order - a.sort_order
},
}
export function getFilteredHotels( export function getFilteredHotels(
hotels: HotelDataWithUrl[], hotels: HotelDataWithUrl[],
filters: string[] filters: string[]
@@ -38,14 +61,35 @@ export function getFilteredHotels(
return hotels return hotels
} }
export function getFilteredCities(
filteredHotels: HotelDataWithUrl[],
cities: DestinationCityListItem[]
) {
const filteredCityIdentifiers = filteredHotels.map(
(hotel) => hotel.cities[0].cityIdentifier
)
return cities.filter((city) =>
filteredCityIdentifiers.includes(city.destination_settings.city)
)
}
export function getSortedCities(
cities: DestinationCityListItem[],
sortOption: SortOption
) {
const sortFn = CITY_SORTING_STRATEGIES[sortOption]
return sortFn ? cities.sort(sortFn) : cities
}
export function getSortedHotels( export function getSortedHotels(
hotels: HotelDataWithUrl[], hotels: HotelDataWithUrl[],
sortOption: SortOption sortOption: SortOption
) { ) {
return hotels.sort(SORTING_STRATAGIES[sortOption]) const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
return sortFn ? hotels.sort(sortFn) : hotels
} }
export const DEFAULT_SORT = SortOption.Distance
export function isValidSortOption( export function isValidSortOption(
value: string, value: string,
sortItems: SortItem[] sortItems: SortItem[]

View File

@@ -0,0 +1,150 @@
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { DestinationDataContext } from "@/contexts/DestinationData"
import {
getBasePathNameWithoutFilters,
getFilteredCities,
getFilteredHotels,
getFiltersFromHotels,
getSortedCities,
getSortedHotels,
isValidSortOption,
} from "./helper"
import type { Filter } from "@/types/components/destinationFilterAndSort"
import type {
DestinationDataState,
InitialState,
} from "@/types/stores/destination-data"
export function createDestinationDataStore({
allCities,
allHotels,
pathname,
sortItems,
}: InitialState) {
const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilters = getFiltersFromHotels(allHotels)
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
filter.map((f) => f.slug)
)
return create<DestinationDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filters, sort) {
return set(
produce((state: DestinationDataState) => {
const newSort =
sort && isValidSortOption(sort, state.sortItems)
? sort
: state.defaultSort
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, newSort)
const filteredCities = state.allHotels.length
? getFilteredCities(filteredHotels, state.allCities)
: []
const sortedCities = getSortedCities(filteredCities, newSort)
state.activeSort = newSort
state.activeFilters = filters
state.activeHotels = sortedHotels
state.activeCities = sortedCities
state.pendingFilters = filters
state.pendingSort = newSort
state.pendingHotelCount = filteredHotels.length
state.pendingCityCount = filteredCities.length
state.isLoading = false
})
)
},
setIsLoading(isLoading) {
return set(
produce((state: DestinationDataState) => {
state.isLoading = isLoading
})
)
},
setPendingSort(sort) {
return set(
produce((state: DestinationDataState) => {
state.pendingSort = sort
})
)
},
togglePendingFilter(filter) {
return set(
produce((state: DestinationDataState) => {
const isActive = state.pendingFilters.includes(filter)
const filters = isActive
? state.pendingFilters.filter((f) => f !== filter)
: [...state.pendingFilters, filter]
const pendingHotels = getFilteredHotels(state.allHotels, filters)
const pendingCities = state.allHotels.length
? getFilteredCities(pendingHotels, state.allCities)
: []
state.pendingFilters = filters
state.pendingHotelCount = pendingHotels.length
state.pendingCityCount = pendingCities.length
})
)
},
clearPendingFilters() {
return set(
produce((state: DestinationDataState) => {
state.pendingFilters = []
state.pendingHotelCount = state.allHotels.length
state.pendingCityCount = state.allCities.length
})
)
},
resetPendingValues() {
return set(
produce((state: DestinationDataState) => {
state.pendingFilters = state.activeFilters
state.pendingSort = state.activeSort
state.pendingHotelCount = state.activeHotels.length
state.pendingCityCount = state.activeCities.length
})
)
},
},
allHotels,
activeHotels: allHotels,
pendingHotelCount: allHotels.length,
allCities,
activeCities: allCities,
pendingCityCount: allCities.length,
activeSort: defaultSort,
pendingSort: defaultSort,
defaultSort,
activeFilters: [],
pendingFilters: [],
allFilters,
allFilterSlugs,
basePathnameWithoutFilters: getBasePathNameWithoutFilters(
pathname,
allFilterSlugs
),
sortItems,
isLoading: true,
}))
}
export function useDestinationDataStore<T>(
selector: (store: DestinationDataState) => T
) {
const store = useContext(DestinationDataContext)
if (!store) {
throw new Error("useHotelDataStore must be used within HotelDataProvider")
}
return useStore(store, selector)
}

View File

@@ -1,159 +0,0 @@
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { HotelDataContext } from "@/contexts/HotelData"
import {
getBasePathNameWithoutFilters,
getFilteredHotels,
getFiltersFromHotels,
getSortedHotels,
isValidSortOption,
} from "./helper"
import type { Filter } from "@/types/components/hotelFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort"
import type { HotelDataState, InitialState } from "@/types/stores/hotel-data"
export function createHotelDataStore({
allHotels,
searchParams,
pathname,
filterFromUrl,
sortItems,
submitCallbackFn,
}: InitialState) {
const sortFromSearchParams = searchParams.get("sort")
const initialFilters = filterFromUrl ? [filterFromUrl] : []
let initialSort = SortOption.Distance
if (
sortFromSearchParams &&
isValidSortOption(sortFromSearchParams, sortItems)
) {
initialSort = sortFromSearchParams
}
const initialFilteredHotels = getFilteredHotels(allHotels, initialFilters)
const initialActiveHotels = getSortedHotels(
initialFilteredHotels,
initialSort
)
const allFilters = getFiltersFromHotels(allHotels)
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
filter.map((f) => f.slug)
)
return create<HotelDataState>((set) => ({
actions: {
submitFiltersAndSort() {
return set(
produce((state: HotelDataState) => {
const sort = state.pendingSort
const filters = state.pendingFilters
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, sort)
state.activeSort = sort
state.activeFilters = state.pendingFilters
state.activeHotels = sortedHotels
state.pendingCount = filteredHotels.length
if (submitCallbackFn) {
submitCallbackFn({
sort,
filters,
basePath: state.basePathnameWithoutFilters,
})
}
})
)
},
setPendingSort(sort) {
return set(
produce((state: HotelDataState) => {
state.pendingSort = sort
})
)
},
togglePendingFilter(filter) {
return set(
produce((state: HotelDataState) => {
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.pendingCount = pendingHotels.length
})
)
},
clearPendingFilters() {
return set(
produce((state: HotelDataState) => {
state.pendingFilters = []
state.pendingCount = state.allHotels.length
})
)
},
resetPendingValues() {
return set(
produce((state: HotelDataState) => {
state.pendingFilters = state.activeFilters
state.pendingSort = state.activeSort
state.pendingCount = state.activeHotels.length
})
)
},
loadInitialHashFilter(hash) {
return set(
produce((state: HotelDataState) => {
state.initialHashFilterLoaded = true
const filters = []
const filtersFromHash = hash.split("&").filter(Boolean) ?? []
if (filterFromUrl) {
filters.push(filterFromUrl, ...filtersFromHash)
}
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(
filteredHotels,
state.activeSort
)
state.activeHotels = sortedHotels
state.activeFilters = filters
state.pendingFilters = filters
state.pendingCount = filteredHotels.length
})
)
},
},
allHotels,
activeHotels: initialActiveHotels,
pendingCount: initialActiveHotels.length,
activeSort: initialSort,
pendingSort: initialSort,
activeFilters: initialFilters,
pendingFilters: initialFilters,
searchParams,
allFilters,
allFilterSlugs,
basePathnameWithoutFilters: getBasePathNameWithoutFilters(
pathname,
allFilterSlugs
),
sortItems,
initialHashFilterLoaded: false,
}))
}
export function useHotelDataStore<T>(selector: (store: HotelDataState) => T) {
const store = useContext(HotelDataContext)
if (!store) {
throw new Error("useHotelDataStore must be used within HotelDataProvider")
}
return useStore(store, selector)
}

View File

@@ -1,8 +1,9 @@
import type { SortOption } from "../enums/hotelFilterAndSort" import type { SortOption } from "../enums/destinationFilterAndSort"
export interface SortItem { export interface SortItem {
label: string label: string
value: SortOption value: SortOption
isDefault?: boolean
} }
export interface Filter { export interface Filter {

View File

@@ -0,0 +1,3 @@
import type { createDestinationDataStore } from "@/stores/destination-data"
export type DestinationDataStore = ReturnType<typeof createDestinationDataStore>

View File

@@ -1,3 +0,0 @@
import type { createHotelDataStore } from "@/stores/hotel-data"
export type HotelDataStore = ReturnType<typeof createHotelDataStore>

View File

@@ -1,4 +1,5 @@
export enum SortOption { export enum SortOption {
Recommended = "recommended",
Distance = "distance", Distance = "distance",
Name = "name", Name = "name",
TripAdvisorRating = "tripadvisor", TripAdvisorRating = "tripadvisor",

View File

@@ -0,0 +1,9 @@
import type { HotelDataWithUrl } from "@/types/hotel"
import type { SortItem } from "../components/destinationFilterAndSort"
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
export interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: HotelDataWithUrl[]
allCities?: DestinationCityListItem[]
sortItems: SortItem[]
}

View File

@@ -1,8 +0,0 @@
import type { HotelDataWithUrl } from "@/types/hotel"
import type { SortItem } from "../components/hotelFilterAndSort"
export interface HotelDataProviderProps extends React.PropsWithChildren {
allHotels: HotelDataWithUrl[]
filterFromUrl?: string
sortItems: SortItem[]
}

View File

@@ -1,46 +1,47 @@
import type { ReadonlyURLSearchParams } from "next/navigation"
import type { import type {
CategorizedFilters, CategorizedFilters,
SortItem, SortItem,
} from "../components/hotelFilterAndSort" } from "../components/destinationFilterAndSort"
import type { SortOption } from "../enums/hotelFilterAndSort" import type { SortOption } from "../enums/destinationFilterAndSort"
import type { HotelDataWithUrl } from "../hotel" import type { HotelDataWithUrl } from "../hotel"
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
interface Actions { interface Actions {
submitFiltersAndSort: () => void updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
setPendingSort: (sort: SortOption) => void setPendingSort: (sort: SortOption) => void
togglePendingFilter: (filter: string) => void togglePendingFilter: (filter: string) => void
clearPendingFilters: () => void clearPendingFilters: () => void
resetPendingValues: () => void resetPendingValues: () => void
loadInitialHashFilter: (hash: string) => void setIsLoading: (isLoading: boolean) => void
} }
export interface SubmitCallbackData { export interface SubmitCallbackData {
sort: SortOption sort: SortOption
defaultSort: SortOption
filters: string[] filters: string[]
basePath: string basePath: string
} }
export interface HotelDataState { export interface DestinationDataState {
actions: Actions actions: Actions
allCities: DestinationCityListItem[]
activeCities: DestinationCityListItem[]
allHotels: HotelDataWithUrl[] allHotels: HotelDataWithUrl[]
activeHotels: HotelDataWithUrl[] activeHotels: HotelDataWithUrl[]
pendingSort: SortOption pendingSort: SortOption
activeSort: SortOption activeSort: SortOption
defaultSort: SortOption
pendingFilters: string[] pendingFilters: string[]
activeFilters: string[] activeFilters: string[]
pendingCount: number pendingHotelCount: number
searchParams: ReadonlyURLSearchParams pendingCityCount: number
allFilters: CategorizedFilters allFilters: CategorizedFilters
allFilterSlugs: string[] allFilterSlugs: string[]
basePathnameWithoutFilters: string basePathnameWithoutFilters: string
sortItems: SortItem[] sortItems: SortItem[]
initialHashFilterLoaded: boolean isLoading: boolean
} }
export interface InitialState export interface InitialState
extends Pick<HotelDataState, "allHotels" | "searchParams" | "sortItems"> { extends Pick<DestinationDataState, "allHotels" | "allCities" | "sortItems"> {
pathname: string pathname: string
filterFromUrl?: string
submitCallbackFn?: (data: SubmitCallbackData) => void
} }

View File

@@ -9,10 +9,14 @@ import type {
export async function generateMetadata({ export async function generateMetadata({
searchParams, searchParams,
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) { }: PageArgs<
const { subpage } = searchParams LangParams & ContentTypeParams & UIDParams,
{ subpage?: string; filterFromUrl?: string }
>) {
const { subpage, filterFromUrl } = searchParams
const metadata = await serverClient().contentstack.metadata.get({ const metadata = await serverClient().contentstack.metadata.get({
subpage, subpage,
filterFromUrl,
}) })
return metadata return metadata
} }