Merged in feat/SW-1452-city-page-filter-2 (pull request #1392)

feat(SW-1452): Added filtering and sorting to destination city pages

* feat(SW-1452): Added filtering and sorting to destination city pages

* feat(SW-1452): Added temporary component for country pages to avoid Context issues


Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-02-25 13:02:38 +00:00
parent 8a564274c5
commit 3867baadd6
53 changed files with 1561 additions and 255 deletions

View File

@@ -32,7 +32,10 @@ export { generateMetadata } from "@/utils/generateMetadata"
export default async function ContentTypePage({
params,
searchParams,
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) {
}: PageArgs<
LangParams & ContentTypeParams & UIDParams,
{ subpage?: string; filterFromUrl?: string }
>) {
const pathname = headers().get("x-pathname") || ""
switch (params.contentType) {
@@ -61,7 +64,8 @@ export default async function ContentTypePage({
case PageContentTypeEnum.destinationCountryPage:
return <DestinationCountryPage />
case PageContentTypeEnum.destinationCityPage:
return <DestinationCityPage />
const filterFromUrl = searchParams.filterFromUrl
return <DestinationCityPage filterFromUrl={filterFromUrl} />
case PageContentTypeEnum.hotelPage:
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()

View File

@@ -4,6 +4,9 @@ import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps"
import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data"
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
import Body from "@/components/TempDesignSystem/Text/Body"
import { debounce } from "@/utils/debounce"
@@ -14,22 +17,23 @@ import styles from "./hotelList.module.css"
import type { HotelDataWithUrl } from "@/types/hotel"
interface HotelListProps {
hotels: HotelDataWithUrl[]
}
export default function HotelList({ hotels }: HotelListProps) {
export default function HotelList() {
const intl = useIntl()
const map = useMap()
const coreLib = useMapsLibrary("core")
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
const { filters, sortItems, activeHotels } = useHotelDataStore((state) => ({
filters: state.allFilters,
sortItems: state.sortItems,
activeHotels: state.activeHotels,
}))
const debouncedUpdateVisibleHotels = useMemo(
() =>
debounce(() => {
setVisibleHotels(getVisibleHotels(hotels, map))
setVisibleHotels(getVisibleHotels(activeHotels, map))
}, 500),
[map, hotels]
[map, activeHotels]
)
useEffect(() => {
@@ -56,6 +60,7 @@ export default function HotelList({ hotels }: HotelListProps) {
{ count: visibleHotels.length }
)}
</Body>
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
</div>
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (

View File

@@ -1,10 +1,12 @@
import { env } from "@/env/server"
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
"use client"
import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import Map from "../../Map"
import { getCityHeadingText } from "../../utils"
import HotelList from "./HotelList"
import styles from "./cityMap.module.css"
@@ -12,32 +14,32 @@ import styles from "./cityMap.module.css"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
interface CityMapProps {
mapId: string
apiKey: string
city: CityLocation
cityIdentifier: string
}
export function preloadHotels(cityIdentifier: string) {
void getHotelsByCityIdentifier(cityIdentifier)
}
export default async function CityMap({ city, cityIdentifier }: CityMapProps) {
const intl = await getIntl()
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useHotelDataStore(
(state) => ({
activeHotels: state.activeHotels,
allFilters: state.allFilters,
activeFilters: state.activeFilters,
})
)
return (
<Map
hotels={hotels}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
pageType="city"
>
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="city">
<Title
level="h2"
as="h3"
textTransform="regular"
className={styles.title}
>
{intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })}
{getCityHeadingText(intl, city.name, allFilters, activeFilters[0])}
</Title>
<HotelList hotels={hotels} />
<HotelList />
</Map>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
import styles from "./destinationCityPage.module.css"
export default function DestinationCityPageSkeleton() {
return (
<div className={styles.pageContainer}>
<header className={styles.header}>
<BreadcrumbsSkeleton />
<TopImagesSkeleton />
</header>
<main className={styles.mainContent}>
<HotelListingSkeleton />
</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

@@ -1,39 +1,45 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { env } from "@/env/server"
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
import ExperienceList from "../ExperienceList"
import HotelDataContainer, { preload } from "../HotelDataContainer"
import HotelListing from "../HotelListing"
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
import TopImages from "../TopImages"
import CityMap, { preloadHotels } from "./CityMap"
import CityMap from "./CityMap"
import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
import styles from "./destinationCityPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCityPage() {
interface DestinationCityPageProps {
filterFromUrl: string | undefined
}
export default async function DestinationCityPage({
filterFromUrl,
}: DestinationCityPageProps) {
const pageData = await getDestinationCityPage()
if (!pageData) {
return null
notFound()
}
const { tracking, destinationCityPage, cityIdentifier, city } = pageData
const {
blocks,
images,
heading,
preamble,
experiences,
has_sidepeek,
@@ -42,42 +48,48 @@ export default async function DestinationCityPage() {
destination_settings,
} = destinationCityPage
preloadHotels(cityIdentifier)
preload(cityIdentifier)
return (
<>
<div className={styles.pageContainer}>
<header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
</Suspense>
<TopImages images={images} destinationName={city.name} />
</header>
<main className={styles.mainContent}>
<Suspense fallback={<HotelListingSkeleton />}>
<HotelListing cityIdentifier={cityIdentifier} />
</Suspense>
{blocks && <Blocks blocks={blocks} />}
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper>
<Title level="h2">{heading}</Title>
<Body color="uiTextMediumContrast">{preamble}</Body>
<ExperienceList experiences={experiences} />
{has_sidepeek && (
<DestinationPageSidePeek
buttonText={sidepeek_button_text}
sidePeekContent={sidepeek_content}
/>
)}
<Suspense fallback={<DestinationCityPageSkeleton />}>
<HotelDataContainer
cityIdentifier={cityIdentifier}
filterFromUrl={filterFromUrl}
>
<div className={styles.pageContainer}>
<header className={styles.header}>
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
<TopImages images={images} destinationName={city.name} />
</header>
<main className={styles.mainContent}>
<HotelListing />
{blocks && <Blocks blocks={blocks} />}
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper cityName={city.name}>
<Body color="uiTextMediumContrast">{preamble}</Body>
<ExperienceList experiences={experiences} />
{has_sidepeek && (
<DestinationPageSidePeek
buttonText={sidepeek_button_text}
sidePeekContent={sidepeek_content}
/>
)}
{destination_settings.city && (
<StaticMap city={destination_settings.city} />
)}
</SidebarContentWrapper>
</aside>
</div>
<CityMap city={city} cityIdentifier={cityIdentifier} />
{destination_settings.city && (
<StaticMap city={destination_settings.city} />
)}
</SidebarContentWrapper>
</aside>
</div>
<CityMap
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
city={city}
/>
</HotelDataContainer>
</Suspense>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>

View File

@@ -0,0 +1,21 @@
"use client"
import React from "react"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./sidebarContentWrapper.module.css"
export default function SidebarContentWrapperSkeleton({
children,
}: React.PropsWithChildren) {
return (
<div className={styles.sidebarContent}>
<div>
<SkeletonShimmer height="40px" width="100%" />
<SkeletonShimmer height="40px" width="80%" />
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { useRef } from "react"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./sidebarContentWrapper.module.css"
export default function SidebarContentWrapper({
children,
}: React.PropsWithChildren) {
const sidebarRef = useRef<HTMLDivElement>(null)
useStickyPosition({
ref: sidebarRef,
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
})
return (
<div ref={sidebarRef} className={styles.sidebarContent}>
{children}
</div>
)
}

View File

@@ -0,0 +1,12 @@
.sidebarContent {
display: grid;
align-content: start;
gap: var(--Spacing-x2);
padding: var(--Spacing-x4);
}
@media screen and (min-width: 1367px) {
.sidebarContent {
position: sticky;
}
}

View File

@@ -12,11 +12,11 @@ import TrackingSDK from "@/components/TrackingSDK"
import CityListing from "../CityListing"
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
import ExperienceList from "../ExperienceList"
import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
import TopImages from "../TopImages"
import CountryMap, { preload } from "./CountryMap"
import SidebarContentWrapper from "./SidebarContentWrapper"
import styles from "./destinationCountryPage.module.css"

View File

@@ -1,8 +1,10 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./experienceList.module.css"
export default async function ExperienceListSkeleton() {
export default function ExperienceListSkeleton() {
return (
<ul className={styles.experienceList}>
{Array.from({ length: 5 }).map((_, index) => (

View File

@@ -0,0 +1,47 @@
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import HotelDataProvider from "@/providers/HotelDataProvider"
import type { SortItem } from "@/types/components/hotelFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort"
interface HotelDataContainerProps extends React.PropsWithChildren {
cityIdentifier: string
filterFromUrl?: string
}
export function preload(cityIdentifier: string) {
void getHotelsByCityIdentifier(cityIdentifier)
}
export default async function HotelDataContainer({
cityIdentifier,
filterFromUrl,
children,
}: HotelDataContainerProps) {
const intl = await getIntl()
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
const sortItems: SortItem[] = [
{
label: intl.formatMessage({ id: "Distance to city center" }),
value: SortOption.Distance,
},
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
{
label: intl.formatMessage({ id: "TripAdvisor rating" }),
value: SortOption.TripAdvisorRating,
},
]
return (
<HotelDataProvider
allHotels={hotels}
filterFromUrl={filterFromUrl}
sortItems={sortItems}
>
{children}
</HotelDataProvider>
)
}

View File

@@ -1,72 +0,0 @@
"use client"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelListingItem from "./HotelListingItem"
import styles from "./hotelListing.module.css"
import type { HotelData } from "@/types/hotel"
interface HotelListingClientProps {
hotels: (HotelData & { url: string | null })[]
}
export default function HotelListingClient({
hotels,
}: HotelListingClientProps) {
const intl = useIntl()
const scrollRef = useRef<HTMLDivElement>(null)
const showToggleButton = hotels.length > 5
const [allHotelsVisible, setAllHotelsVisible] = useState(!showToggleButton)
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300,
elementRef: scrollRef,
})
function handleShowMore() {
if (scrollRef.current && allHotelsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllHotelsVisible((state) => !state)
}
return (
<section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}>
<Subtitle type="two">
{intl.formatMessage(
{
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
},
{ count: hotels.length }
)}
</Subtitle>
</div>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{hotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</section>
)
}

View File

@@ -1,19 +1,90 @@
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
"use client"
import HotelListingClient from "./Client"
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
interface HotelListingProps {
cityIdentifier: string
}
import { useHotelDataStore } from "@/stores/hotel-data"
export default async function HotelListing({
cityIdentifier,
}: HotelListingProps) {
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useHash from "@/hooks/useHash"
import { useScrollToTop } from "@/hooks/useScrollToTop"
if (!hotels.length) {
return null
import HotelListingItem from "./HotelListingItem"
import styles from "./hotelListing.module.css"
export default function HotelListing() {
const intl = useIntl()
const hash = useHash()
const scrollRef = useRef<HTMLDivElement>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300,
elementRef: scrollRef,
})
const {
activeHotels,
filters,
sortItems,
initialHashFilterLoaded,
loadInitialHashFilter,
} = useHotelDataStore((state) => ({
activeHotels: state.activeHotels,
filters: state.allFilters,
sortItems: state.sortItems,
initialHashFilterLoaded: state.initialHashFilterLoaded,
loadInitialHashFilter: state.actions.loadInitialHashFilter,
}))
const [allHotelsVisible, setAllHotelsVisible] = useState(
activeHotels.length <= 5
)
useEffect(() => {
if (hash !== undefined && !initialHashFilterLoaded) {
loadInitialHashFilter(hash)
}
}, [hash, loadInitialHashFilter, initialHashFilterLoaded])
function handleShowMore() {
if (scrollRef.current && allHotelsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllHotelsVisible((state) => !state)
}
return <HotelListingClient hotels={hotels} />
return (
<section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}>
<Subtitle type="two">
{intl.formatMessage(
{
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
},
{ count: activeHotels.length }
)}
</Subtitle>
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
</div>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{activeHotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{activeHotels.length > 5 ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</section>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import {
type PropsWithChildren,
useCallback,
@@ -36,6 +36,7 @@ export default function Map({
children,
pageType,
}: PropsWithChildren<MapProps>) {
const router = useRouter()
const searchParams = useSearchParams()
const isMapView = useMemo(
() => searchParams.get("view") === "map",
@@ -91,7 +92,9 @@ export default function Map({
}, [rootDiv, isMapView, handleMapHeight])
function handleClose() {
window.history.pushState({}, "", window.location.pathname)
const url = new URL(window.location.href)
url.searchParams.delete("view")
router.push(url.toString())
}
return (

View File

@@ -0,0 +1,21 @@
"use client"
import React from "react"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./sidebarContentWrapper.module.css"
export default function SidebarContentWrapperSkeleton({
children,
}: React.PropsWithChildren) {
return (
<div className={styles.sidebarContent}>
<div>
<SkeletonShimmer height="40px" width="100%" />
<SkeletonShimmer height="40px" width="80%" />
</div>
{children}
</div>
)
}

View File

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

View File

@@ -0,0 +1,45 @@
"use client"
import Link from "next/link"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { MapIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
interface MapButtonProps {
className?: string
}
export default function MapButton({ className = "" }: MapButtonProps) {
const intl = useIntl()
const params = useParams()
const [mapUrl, setMapUrl] = useState<string | null>(null)
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
if (!mapUrl) {
return null
}
return (
<Button
intent="inverted"
variant="icon"
size="small"
theme="base"
className={className}
asChild
>
<Link href={mapUrl} scroll={true}>
<MapIcon />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
)
}

View File

@@ -1,10 +1,8 @@
import Link from "next/link"
import { MapIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Button from "@/components/TempDesignSystem/Button"
import { getIntl } from "@/i18n"
import MapButton from "./MapButton"
import styles from "./staticMap.module.css"
interface StaticMapProps {
@@ -32,19 +30,7 @@ export default async function DestinationStaticMap({
zoomLevel={zoomLevel}
altText={altText}
/>
<Button
intent="inverted"
variant="icon"
size="small"
theme="base"
className={styles.button}
asChild
>
<Link href="?view=map">
<MapIcon />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
<MapButton className={styles.button} />
</div>
)
}

View File

@@ -0,0 +1,13 @@
"use client"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./topImages.module.css"
export default function TopImagesSkeleton() {
return (
<div className={styles.topImages}>
<SkeletonShimmer height="400px" width="100%" />
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type { IntlShape } from "react-intl"
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
export function getCityHeadingText(
intl: IntlShape,
cityName: 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 {cityName}" },
{ cityName, filter: facilityFilter.name }
)
} else if (surroudingsFilter) {
return intl.formatMessage(
{ id: "Hotels near {filter} in {cityName}" },
{ cityName, filter: surroudingsFilter.name }
)
}
}
return intl.formatMessage({ id: "Hotels in {city}" }, { city: cityName })
}

View File

@@ -0,0 +1,31 @@
.checkboxWrapper {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
cursor: pointer;
border-radius: var(--Corner-radius-Medium);
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-Small);
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);
}

View File

@@ -0,0 +1,41 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import CheckIcon from "@/components/Icons/Check"
import Body from "@/components/TempDesignSystem/Text/Body"
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 (
<AriaCheckbox
className={styles.checkboxWrapper}
isSelected={isSelected}
onChange={() => onChange(value)}
>
{({ isSelected }) => (
<>
<span className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</span>
<Body asChild>
<span>{name}</span>
</Body>
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,27 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}
.form {
display: grid;
gap: var(--Spacing-x4);
}
.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(--Spacing-x1) var(--Spacing-x2);
margin: var(--Spacing-x3) 0;
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import Checkbox from "./Checkbox"
import styles from "./filter.module.css"
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
interface FilterProps {
filters: CategorizedFilters
}
export default function Filter({ filters }: FilterProps) {
const intl = useIntl()
const { facilityFilters, surroundingsFilters } = filters
const { pendingFilters, togglePendingFilter } = useHotelDataStore(
(state) => ({
pendingFilters: state.pendingFilters,
togglePendingFilter: state.actions.togglePendingFilter,
})
)
if (!facilityFilters.length && !surroundingsFilters.length) {
return null
}
return (
<div className={styles.container}>
<Title level="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<form className={styles.form}>
<fieldset className={styles.fieldset}>
<Subtitle type="two" asChild>
<legend>{intl.formatMessage({ id: "Hotel facilities" })}</legend>
</Subtitle>
<ul className={styles.list}>
{facilityFilters.map((filter) => (
<li key={`filter-${filter.slug}`} className={styles.item}>
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
/>
</li>
))}
</ul>
</fieldset>
<fieldset className={styles.fieldset}>
<Subtitle type="two" asChild>
<legend>{intl.formatMessage({ id: "Hotel surroundings" })}</legend>
</Subtitle>
<ul className={styles.list}>
{surroundingsFilters.map((filter) => (
<li key={`filter-${filter.slug}`} className={styles.item}>
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
/>
</li>
))}
</ul>
</fieldset>
</form>
</div>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data"
import Select from "@/components/TempDesignSystem/Select"
import type { SortItem } from "@/types/components/hotelFilterAndSort"
import type { SortOption } from "@/types/enums/hotelFilterAndSort"
interface SortProps {
sortItems: SortItem[]
}
export default function Sort({ sortItems }: SortProps) {
const intl = useIntl()
const { pendingSort, setPendingSort } = useHotelDataStore((state) => ({
pendingSort: state.pendingSort,
setPendingSort: state.actions.setPendingSort,
}))
return (
<Select
items={sortItems}
defaultSelectedKey={pendingSort}
label={intl.formatMessage({ id: "Sort by" })}
aria-label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
onSelect={(sort) => setPendingSort(sort as SortOption)}
/>
)
}

View File

@@ -0,0 +1,58 @@
.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-Large);
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(--Spacing-x2) var(--Spacing-x3);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.buttonWrapper {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.badge {
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-xLarge);
width: 20px;
height: 20px;
color: var(--Base-Surface-Primary-light-Normal);
display: flex;
align-items: center;
justify-content: center;
}
.content {
display: grid;
gap: var(--Spacing-x4);
align-content: start;
padding: var(--Spacing-x4) var(--Spacing-x3);
overflow-y: auto;
height: min(calc(80dvh - 180px), 500px);
}
.footer {
display: flex;
justify-content: space-between;
padding: var(--Spacing-x2) var(--Spacing-x4);
border-top: 1px solid var(--Base-Border-Subtle);
}

View File

@@ -0,0 +1,135 @@
"use client"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { useHotelDataStore } from "@/stores/hotel-data"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Filter from "./Filter"
import Sort from "./Sort"
import styles from "./hotelFilterAndSort.module.css"
import type {
CategorizedFilters,
SortItem,
} from "@/types/components/hotelFilterAndSort"
interface HotelFilterAndSortProps {
filters: CategorizedFilters
sortItems: SortItem[]
}
export default function HotelFilterAndSort({
filters,
sortItems,
}: HotelFilterAndSortProps) {
const intl = useIntl()
const {
pendingCount,
activeFilters,
clearPendingFilters,
resetPendingValues,
submitFiltersAndSort,
} = useHotelDataStore((state) => ({
pendingCount: state.pendingCount,
activeFilters: state.activeFilters,
clearPendingFilters: state.actions.clearPendingFilters,
resetPendingValues: state.actions.resetPendingValues,
submitFiltersAndSort: state.actions.submitFiltersAndSort,
}))
function submitAndClose(close: () => void) {
submitFiltersAndSort()
close()
}
function handleClose(isOpen: boolean) {
if (isOpen) {
resetPendingValues()
}
}
return (
<>
<DialogTrigger onOpenChange={handleClose}>
<div className={styles.buttonWrapper}>
<Button intent="text" theme="base" variant="icon">
<FilterIcon color="baseTextHighcontrast" />
{intl.formatMessage({ id: "Filter and sort" })}
</Button>
{activeFilters.length > 0 && (
<Footnote className={styles.badge} asChild>
<span>{activeFilters.length}</span>
</Footnote>
)}
</div>
<ModalOverlay isDismissable className={styles.overlay}>
<Modal>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Filter and sort" })}
>
{({ close }) => (
<>
<header className={styles.header}>
<Subtitle
type="two"
textAlign="center"
className={styles.title}
asChild
>
<h3>{intl.formatMessage({ id: "Filter and sort" })}</h3>
</Subtitle>
<Button onClick={close} variant="icon" intent="tertiary">
<CloseLargeIcon />
</Button>
</header>
<div className={styles.content}>
<Sort sortItems={sortItems} />
<Divider color="subtle" />
<Filter filters={filters} />
</div>
<footer className={styles.footer}>
<Button
onClick={clearPendingFilters}
intent="text"
size="medium"
theme="base"
>
{intl.formatMessage({ id: "Clear all filters" })}
</Button>
<Button
intent="tertiary"
size="large"
theme="base"
onClick={() => submitAndClose(close)}
>
{intl.formatMessage(
{ id: "See results ({ count })" },
{
count: pendingCount,
}
)}
</Button>
</footer>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</>
)
}

5
contexts/HotelData.ts Normal file
View File

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

View File

@@ -277,6 +277,8 @@
"Hotel surroundings": "Hotel omgivelser",
"Hotels": "Hoteller",
"Hotels & Destinations": "Hoteller & destinationer",
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}",
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
"Hours": "Tider",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker",

View File

@@ -278,6 +278,8 @@
"Hotel surroundings": "Umgebung des Hotels",
"Hotels": "Hotels",
"Hotels & Destinations": "Hotels & Reiseziele",
"Hotels near {filter} in {cityName}": "Hotels in der Nähe von {filter} in {cityName}",
"Hotels with {filter} in {cityName}": "Hotels mit {filter} in {cityName}",
"Hours": "Zeiten",
"How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert",

View File

@@ -281,6 +281,8 @@
"Hotels": "Hotels",
"Hotels & Destinations": "Hotels & Destinations",
"Hotels in {city}": "Hotels in {city}",
"Hotels near {filter} in {cityName}": "Hotels near {filter} in {cityName}",
"Hotels with {filter} in {cityName}": "Hotels with {filter} in {cityName}",
"Hours": "Hours",
"How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works",

View File

@@ -277,6 +277,8 @@
"Hotel surroundings": "Hotellin ympäristö",
"Hotels": "Hotellit",
"Hotels & Destinations": "Hotellit ja Kohteet",
"Hotels near {filter} in {cityName}": "Hotellit lähellä {filter} kaupungissa {cityName}",
"Hotels with {filter} in {cityName}": "Hotellit, joissa on {filter} kaupungissa {cityName}",
"Hours": "Ajat",
"How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii",

View File

@@ -276,6 +276,8 @@
"Hotel surroundings": "Hotellomgivelser",
"Hotels": "Hoteller",
"Hotels & Destinations": "Hoteller og Destinasjoner",
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}",
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
"Hours": "Tider",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer",

View File

@@ -276,6 +276,8 @@
"Hotel surroundings": "Hotellomgivning",
"Hotels": "Hotell",
"Hotels & Destinations": "Hotell & destinationer",
"Hotels near {filter} in {cityName}": "Hotell nära {filter} i {cityName}",
"Hotels with {filter} in {cityName}": "Hotell med {filter} i {cityName}",
"Hours": "Tider",
"How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar",

View File

@@ -11,6 +11,14 @@ query GetDestinationCityPageMetadata($locale: String!, $uid: String!) {
...Metadata
}
}
destination_settings {
city_denmark
city_finland
city_germany
city_norway
city_poland
city_sweden
}
system {
...System
}

View File

@@ -40,13 +40,17 @@ export const middleware: NextMiddleware = async (request) => {
await getUidAndContentTypeByPath(incomingPathNameParts.join("/"))
if (parentUid) {
contentType = parentContentType
uid = parentUid
switch (parentContentType) {
case PageContentTypeEnum.hotelPage:
// E.g. Dedicated pages for restaurant, parking etc.
contentType = parentContentType
uid = parentUid
searchParams.set("subpage", subpage)
break
case PageContentTypeEnum.destinationCityPage:
// E.g. Active filters inside destination pages to filter hotels.
searchParams.set("filterFromUrl", subpage)
break
}
}
}

9
package-lock.json generated
View File

@@ -69,6 +69,7 @@
"react-intl": "^6.6.8",
"react-to-print": "^3.0.2",
"server-only": "^0.0.1",
"slugify": "^1.6.6",
"sonner": "^1.7.0",
"supercluster": "^8.0.1",
"superjson": "^2.2.1",
@@ -20956,6 +20957,14 @@
"node": ">=8"
}
},
"node_modules/slugify": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
"integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",

View File

@@ -84,6 +84,7 @@
"react-intl": "^6.6.8",
"react-to-print": "^3.0.2",
"server-only": "^0.0.1",
"slugify": "^1.6.6",
"sonner": "^1.7.0",
"supercluster": "^8.0.1",
"superjson": "^2.2.1",

View File

@@ -0,0 +1,65 @@
"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

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

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
import { attributesSchema as hotelAttributesSchema } from "../../hotels/schemas/hotel"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { systemSchema } from "../schemas/system"
import { getDescription, getImage, getTitle } from "./utils"
import type { Metadata } from "next"
@@ -61,6 +62,17 @@ export const rawMetadataSchema = z.object({
})
.optional()
.nullable(),
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(),
})
.optional()
.nullable(),
heading: z.string().optional().nullable(),
preamble: z.string().optional().nullable(),
header: z
@@ -77,6 +89,10 @@ export const rawMetadataSchema = z.object({
.pick({ name: true, address: true, hotelContent: true, gallery: true })
.optional()
.nullable(),
cityName: z.string().optional().nullable(),
cityFilter: z.string().optional().nullable(),
cityFilterType: z.enum(["facility", "surroundings"]).optional().nullable(),
system: systemSchema,
})
export const metadataSchema = rawMetadataSchema.transform(async (data) => {

View File

@@ -16,8 +16,9 @@ import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
import { getHotel } from "../../hotels/query"
import { getMetadataInput } from "./input"
import { metadataSchema } from "./output"
import { affix } from "./utils"
import { affix, getCityData } from "./utils"
import { PageContentTypeEnum } from "@/types/requests/contentType"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
@@ -117,76 +118,85 @@ async function getTransformedMetadata(data: unknown) {
}
export const metadataQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const variables = {
lang: ctx.lang,
uid: ctx.uid,
}
get: contentStackUidWithServiceProcedure
.input(getMetadataInput)
.query(async ({ ctx, input }) => {
const variables = {
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageContentTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
return getTransformedMetadata(accountPageResponse.account_page)
case PageContentTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema
}>(GetCollectionPageMetadata, variables)
return getTransformedMetadata(collectionPageResponse.collection_page)
case PageContentTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
return getTransformedMetadata(contentPageResponse.content_page)
case PageContentTypeEnum.destinationOverviewPage:
const destinationOverviewPageResponse = await fetchMetadata<{
destination_overview_page: RawMetadataSchema
}>(GetDestinationOverviewPageMetadata, variables)
return getTransformedMetadata(
destinationOverviewPageResponse.destination_overview_page
)
case PageContentTypeEnum.destinationCountryPage:
const destinationCountryPageResponse = await fetchMetadata<{
destination_country_page: RawMetadataSchema
}>(GetDestinationCountryPageMetadata, variables)
return getTransformedMetadata(
destinationCountryPageResponse.destination_country_page
)
case PageContentTypeEnum.destinationCityPage:
const destinationCityPageResponse = await fetchMetadata<{
destination_city_page: RawMetadataSchema
}>(GetDestinationCityPageMetadata, variables)
return getTransformedMetadata(
destinationCityPageResponse.destination_city_page
)
case PageContentTypeEnum.loyaltyPage:
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
case PageContentTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotel(
{
hotelId: hotelPageData.hotel_page_id,
isCardOnlyPayment: false,
language: ctx.lang,
},
ctx.serviceToken
)
: null
switch (ctx.contentType) {
case PageContentTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
return getTransformedMetadata(accountPageResponse.account_page)
case PageContentTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema
}>(GetCollectionPageMetadata, variables)
return getTransformedMetadata(collectionPageResponse.collection_page)
case PageContentTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
return getTransformedMetadata(contentPageResponse.content_page)
case PageContentTypeEnum.destinationOverviewPage:
const destinationOverviewPageResponse = await fetchMetadata<{
destination_overview_page: RawMetadataSchema
}>(GetDestinationOverviewPageMetadata, variables)
return getTransformedMetadata(
destinationOverviewPageResponse.destination_overview_page
)
case PageContentTypeEnum.destinationCountryPage:
const destinationCountryPageResponse = await fetchMetadata<{
destination_country_page: RawMetadataSchema
}>(GetDestinationCountryPageMetadata, variables)
return getTransformedMetadata(
destinationCountryPageResponse.destination_country_page
)
case PageContentTypeEnum.destinationCityPage:
const destinationCityPageResponse = await fetchMetadata<{
destination_city_page: RawMetadataSchema
}>(GetDestinationCityPageMetadata, variables)
const cityData = await getCityData(
destinationCityPageResponse.destination_city_page,
input,
ctx.serviceToken,
ctx.lang
)
return getTransformedMetadata({
...destinationCityPageResponse.destination_city_page,
...cityData,
})
case PageContentTypeEnum.loyaltyPage:
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
case PageContentTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotel(
{
hotelId: hotelPageData.hotel_page_id,
isCardOnlyPayment: false,
language: ctx.lang,
},
ctx.serviceToken
)
: null
return getTransformedMetadata({
...hotelPageData,
hotelData: hotelData?.hotel,
})
default:
return null
}
}),
return getTransformedMetadata({
...hotelPageData,
hotelData: hotelData?.hotel,
})
default:
return null
}
}),
})

View File

@@ -1,7 +1,19 @@
import { getFiltersFromHotels } from "@/stores/hotel-data/helper"
import { getIntl } from "@/i18n"
import {
getCityByCityIdentifier,
getHotelIdsByCityIdentifier,
getHotelsByHotelIds,
} from "../../hotels/utils"
import { RTETypeEnum } from "@/types/rte/enums"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
import type {
MetadataInputSchema,
RawMetadataSchema,
} from "@/types/trpc/routers/contentstack/metadata"
import type { Lang } from "@/constants/languages"
export const affix = "metadata"
@@ -75,6 +87,27 @@ export async function getTitle(data: RawMetadataSchema) {
}
)
}
if (data.system.content_type_uid === "destination_city_page") {
if (data.cityName) {
if (data.cityFilter) {
if (data.cityFilterType === "facility") {
return intl.formatMessage(
{ id: "Hotels with {filter} in {cityName}" },
{ cityName: data.cityName, filter: data.cityFilter }
)
} else if (data.cityFilterType === "surroundings") {
return intl.formatMessage(
{ id: "Hotels near {filter} in {cityName}" },
{ cityName: data.cityName, filter: data.cityFilter }
)
}
}
return intl.formatMessage(
{ id: "Hotels in {city}" },
{ city: data.cityName }
)
}
}
if (data.web?.breadcrumbs?.title) {
return data.web.breadcrumbs.title
}
@@ -149,3 +182,64 @@ export function getImage(data: RawMetadataSchema) {
}
return undefined
}
export async function getCityData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const destinationSettings = data.destination_settings
const cityFilter = input.subpage
let cityIdentifier
let cityData
let filterType
if (destinationSettings) {
const {
city_sweden,
city_norway,
city_denmark,
city_finland,
city_germany,
city_poland,
} = destinationSettings
const cities = [
city_denmark,
city_finland,
city_germany,
city_poland,
city_norway,
city_sweden,
].filter((city): city is string => Boolean(city))
cityIdentifier = cities[0]
if (cityIdentifier) {
cityData = await getCityByCityIdentifier(cityIdentifier, serviceToken)
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
if (cityFilter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === cityFilter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === cityFilter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
}
return { cityName: cityData?.name, cityFilter, cityFilterType: filterType }
}
return null
}

View File

@@ -1,3 +1,4 @@
import slugify from "slugify"
import { z } from "zod"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
@@ -5,7 +6,7 @@ import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { FacilityEnum } from "@/types/enums/facilities"
export const detailedFacilitySchema = z.object({
const rawDetailedFacilitySchema = z.object({
filter: nullableStringValidator,
icon: nullableStringValidator,
id: z.nativeEnum(FacilityEnum),
@@ -14,8 +15,23 @@ export const detailedFacilitySchema = z.object({
sortOrder: z.number(),
})
export const detailedFacilitiesSchema = nullableArrayObjectValidator(
detailedFacilitySchema
).transform((facilities) =>
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
function transformDetailedFacility(
data: z.output<typeof rawDetailedFacilitySchema>
) {
return {
...data,
slug: slugify(data.name, { lower: true, strict: true }),
}
}
export const detailedFacilitySchema = rawDetailedFacilitySchema.transform(
transformDetailedFacility
)
export const detailedFacilitiesSchema = nullableArrayObjectValidator(
rawDetailedFacilitySchema
).transform((facilities) =>
facilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.map(transformDetailedFacility)
)

118
stores/hotel-data/helper.ts Normal file
View File

@@ -0,0 +1,118 @@
import type {
CategorizedFilters,
Filter,
SortItem,
} from "@/types/components/hotelFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort"
import type { HotelDataWithUrl } from "@/types/hotel"
export const SORTING_STRATAGIES: Record<
SortOption,
(a: HotelDataWithUrl, b: HotelDataWithUrl) => number
> = {
[SortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name)
},
[SortOption.TripAdvisorRating]: function (a, b) {
return (
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
(a.hotel.ratings?.tripAdvisor.rating ?? 0)
)
},
[SortOption.Distance]: function (a, b) {
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
},
}
export function getFilteredHotels(
hotels: HotelDataWithUrl[],
filters: string[]
) {
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) =>
hotel.detailedFacilities.some((facility) => facility.slug === filter)
)
)
}
return hotels
}
export function getSortedHotels(
hotels: HotelDataWithUrl[],
sortOption: SortOption
) {
return hotels.sort(SORTING_STRATAGIES[sortOption])
}
export const DEFAULT_SORT = SortOption.Distance
export function isValidSortOption(
value: string,
sortItems: SortItem[]
): value is SortOption {
return sortItems.map((item) => item.value).includes(value as SortOption)
}
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: HotelDataWithUrl[]
): CategorizedFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [] }
}
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
const sortedFilters = filters.sort((a, b) => b.sortOrder - a.sortOrder)
const uniqueFilterNames = [
...new Set(sortedFilters.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,
}
: null
})
.filter((filter): filter is Filter => !!filter)
return {
facilityFilters: filterList.filter((filter) =>
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
),
surroundingsFilters: filterList.filter((filter) =>
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
),
}
}
export function getBasePathNameWithoutFilters(
pathname: string,
filterSlugs: string[]
) {
const pathSegments = pathname.split("/")
const filteredSegments = pathSegments.filter(
(segment) => !filterSlugs.includes(segment)
)
return filteredSegments.join("/")
}

159
stores/hotel-data/index.ts Normal file
View File

@@ -0,0 +1,159 @@
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

@@ -0,0 +1,17 @@
import type { SortOption } from "../enums/hotelFilterAndSort"
export interface SortItem {
label: string
value: SortOption
}
export interface Filter {
name: string
slug: string
filterType: string
}
export interface CategorizedFilters {
facilityFilters: Filter[]
surroundingsFilters: Filter[]
}

View File

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

View File

@@ -0,0 +1,5 @@
export enum SortOption {
Distance = "distance",
Name = "name",
TripAdvisorRating = "tripadvisor",
}

View File

@@ -0,0 +1,8 @@
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

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

View File

@@ -1,5 +1,8 @@
import { z } from "zod"
import type { z } from "zod"
import { rawMetadataSchema } from "@/server/routers/contentstack/metadata/output"
import type { getMetadataInput } from "@/server/routers/contentstack/metadata/input"
import type { rawMetadataSchema } from "@/server/routers/contentstack/metadata/output"
export interface RawMetadataSchema extends z.output<typeof rawMetadataSchema> {}
export interface MetadataInputSchema extends z.input<typeof getMetadataInput> {}

View File

@@ -1,5 +1,18 @@
import { serverClient } from "@/lib/trpc/server"
export async function generateMetadata() {
return await serverClient().contentstack.metadata.get()
import type {
ContentTypeParams,
LangParams,
PageArgs,
UIDParams,
} from "@/types/params"
export async function generateMetadata({
searchParams,
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) {
const { subpage } = searchParams
const metadata = await serverClient().contentstack.metadata.get({
subpage,
})
return metadata
}