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:
@@ -32,7 +32,10 @@ export { generateMetadata } from "@/utils/generateMetadata"
|
|||||||
export default async function ContentTypePage({
|
export default async function ContentTypePage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) {
|
}: PageArgs<
|
||||||
|
LangParams & ContentTypeParams & UIDParams,
|
||||||
|
{ subpage?: string; filterFromUrl?: string }
|
||||||
|
>) {
|
||||||
const pathname = headers().get("x-pathname") || ""
|
const pathname = headers().get("x-pathname") || ""
|
||||||
|
|
||||||
switch (params.contentType) {
|
switch (params.contentType) {
|
||||||
@@ -61,7 +64,8 @@ export default async function ContentTypePage({
|
|||||||
case PageContentTypeEnum.destinationCountryPage:
|
case PageContentTypeEnum.destinationCountryPage:
|
||||||
return <DestinationCountryPage />
|
return <DestinationCountryPage />
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
return <DestinationCityPage />
|
const filterFromUrl = searchParams.filterFromUrl
|
||||||
|
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()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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 HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import { debounce } from "@/utils/debounce"
|
import { debounce } from "@/utils/debounce"
|
||||||
|
|
||||||
@@ -14,22 +17,23 @@ import styles from "./hotelList.module.css"
|
|||||||
|
|
||||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||||
|
|
||||||
interface HotelListProps {
|
export default function HotelList() {
|
||||||
hotels: HotelDataWithUrl[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HotelList({ hotels }: HotelListProps) {
|
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
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) => ({
|
||||||
|
filters: state.allFilters,
|
||||||
|
sortItems: state.sortItems,
|
||||||
|
activeHotels: state.activeHotels,
|
||||||
|
}))
|
||||||
|
|
||||||
const debouncedUpdateVisibleHotels = useMemo(
|
const debouncedUpdateVisibleHotels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce(() => {
|
debounce(() => {
|
||||||
setVisibleHotels(getVisibleHotels(hotels, map))
|
setVisibleHotels(getVisibleHotels(activeHotels, map))
|
||||||
}, 500),
|
}, 500),
|
||||||
[map, hotels]
|
[map, activeHotels]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,6 +60,7 @@ export default function HotelList({ hotels }: HotelListProps) {
|
|||||||
{ count: visibleHotels.length }
|
{ count: visibleHotels.length }
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
|
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.hotelList}>
|
<ul className={styles.hotelList}>
|
||||||
{visibleHotels.map(({ hotel, url }) => (
|
{visibleHotels.map(({ hotel, url }) => (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { env } from "@/env/server"
|
"use client"
|
||||||
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useHotelDataStore } from "@/stores/hotel-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 { getCityHeadingText } from "../../utils"
|
||||||
import HotelList from "./HotelList"
|
import HotelList from "./HotelList"
|
||||||
|
|
||||||
import styles from "./cityMap.module.css"
|
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"
|
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
||||||
|
|
||||||
interface CityMapProps {
|
interface CityMapProps {
|
||||||
|
mapId: string
|
||||||
|
apiKey: string
|
||||||
city: CityLocation
|
city: CityLocation
|
||||||
cityIdentifier: string
|
|
||||||
}
|
}
|
||||||
export function preloadHotels(cityIdentifier: string) {
|
|
||||||
void getHotelsByCityIdentifier(cityIdentifier)
|
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
|
||||||
}
|
const intl = useIntl()
|
||||||
export default async function CityMap({ city, cityIdentifier }: CityMapProps) {
|
const { activeHotels, allFilters, activeFilters } = useHotelDataStore(
|
||||||
const intl = await getIntl()
|
(state) => ({
|
||||||
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
|
activeHotels: state.activeHotels,
|
||||||
|
allFilters: state.allFilters,
|
||||||
|
activeFilters: state.activeFilters,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="city">
|
||||||
hotels={hotels}
|
|
||||||
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
|
||||||
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
|
||||||
pageType="city"
|
|
||||||
>
|
|
||||||
<Title
|
<Title
|
||||||
level="h2"
|
level="h2"
|
||||||
as="h3"
|
as="h3"
|
||||||
textTransform="regular"
|
textTransform="regular"
|
||||||
className={styles.title}
|
className={styles.title}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })}
|
{getCityHeadingText(intl, city.name, allFilters, activeFilters[0])}
|
||||||
</Title>
|
</Title>
|
||||||
<HotelList hotels={hotels} />
|
<HotelList />
|
||||||
</Map>
|
</Map>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,39 +1,45 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
|
import { getDestinationCityPage } 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 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 ExperienceList from "../ExperienceList"
|
import ExperienceList from "../ExperienceList"
|
||||||
|
import HotelDataContainer, { preload } from "../HotelDataContainer"
|
||||||
import HotelListing from "../HotelListing"
|
import HotelListing from "../HotelListing"
|
||||||
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
|
|
||||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
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 CityMap, { preloadHotels } from "./CityMap"
|
import CityMap from "./CityMap"
|
||||||
|
import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
|
||||||
|
|
||||||
import styles from "./destinationCityPage.module.css"
|
import styles from "./destinationCityPage.module.css"
|
||||||
|
|
||||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
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()
|
const pageData = await getDestinationCityPage()
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
return null
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tracking, destinationCityPage, cityIdentifier, city } = pageData
|
const { tracking, destinationCityPage, cityIdentifier, city } = pageData
|
||||||
const {
|
const {
|
||||||
blocks,
|
blocks,
|
||||||
images,
|
images,
|
||||||
heading,
|
|
||||||
preamble,
|
preamble,
|
||||||
experiences,
|
experiences,
|
||||||
has_sidepeek,
|
has_sidepeek,
|
||||||
@@ -42,42 +48,48 @@ export default async function DestinationCityPage() {
|
|||||||
destination_settings,
|
destination_settings,
|
||||||
} = destinationCityPage
|
} = destinationCityPage
|
||||||
|
|
||||||
preloadHotels(cityIdentifier)
|
preload(cityIdentifier)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.pageContainer}>
|
<Suspense fallback={<DestinationCityPageSkeleton />}>
|
||||||
<header className={styles.header}>
|
<HotelDataContainer
|
||||||
<Suspense fallback={<BreadcrumbsSkeleton />}>
|
cityIdentifier={cityIdentifier}
|
||||||
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
filterFromUrl={filterFromUrl}
|
||||||
</Suspense>
|
>
|
||||||
<TopImages images={images} destinationName={city.name} />
|
<div className={styles.pageContainer}>
|
||||||
</header>
|
<header className={styles.header}>
|
||||||
<main className={styles.mainContent}>
|
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
||||||
<Suspense fallback={<HotelListingSkeleton />}>
|
<TopImages images={images} destinationName={city.name} />
|
||||||
<HotelListing cityIdentifier={cityIdentifier} />
|
</header>
|
||||||
</Suspense>
|
<main className={styles.mainContent}>
|
||||||
{blocks && <Blocks blocks={blocks} />}
|
<HotelListing />
|
||||||
</main>
|
{blocks && <Blocks blocks={blocks} />}
|
||||||
<aside className={styles.sidebar}>
|
</main>
|
||||||
<SidebarContentWrapper>
|
<aside className={styles.sidebar}>
|
||||||
<Title level="h2">{heading}</Title>
|
<SidebarContentWrapper cityName={city.name}>
|
||||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||||
<ExperienceList experiences={experiences} />
|
<ExperienceList experiences={experiences} />
|
||||||
{has_sidepeek && (
|
{has_sidepeek && (
|
||||||
<DestinationPageSidePeek
|
<DestinationPageSidePeek
|
||||||
buttonText={sidepeek_button_text}
|
buttonText={sidepeek_button_text}
|
||||||
sidePeekContent={sidepeek_content}
|
sidePeekContent={sidepeek_content}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{destination_settings.city && (
|
{destination_settings.city && (
|
||||||
<StaticMap city={destination_settings.city} />
|
<StaticMap city={destination_settings.city} />
|
||||||
)}
|
)}
|
||||||
</SidebarContentWrapper>
|
</SidebarContentWrapper>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<CityMap city={city} cityIdentifier={cityIdentifier} />
|
<CityMap
|
||||||
|
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
||||||
|
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
||||||
|
city={city}
|
||||||
|
/>
|
||||||
|
</HotelDataContainer>
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TrackingSDK pageData={tracking} />
|
<TrackingSDK pageData={tracking} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,11 @@ import TrackingSDK from "@/components/TrackingSDK"
|
|||||||
import CityListing from "../CityListing"
|
import CityListing from "../CityListing"
|
||||||
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
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, { preload } from "./CountryMap"
|
||||||
|
import SidebarContentWrapper from "./SidebarContentWrapper"
|
||||||
|
|
||||||
import styles from "./destinationCountryPage.module.css"
|
import styles from "./destinationCountryPage.module.css"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
import styles from "./experienceList.module.css"
|
import styles from "./experienceList.module.css"
|
||||||
|
|
||||||
export default async function ExperienceListSkeleton() {
|
export default function ExperienceListSkeleton() {
|
||||||
return (
|
return (
|
||||||
<ul className={styles.experienceList}>
|
<ul className={styles.experienceList}>
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 {
|
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||||
cityIdentifier: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function HotelListing({
|
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
||||||
cityIdentifier,
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
}: HotelListingProps) {
|
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||||
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import useHash from "@/hooks/useHash"
|
||||||
|
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||||
|
|
||||||
if (!hotels.length) {
|
import HotelListingItem from "./HotelListingItem"
|
||||||
return null
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -36,6 +36,7 @@ export default function Map({
|
|||||||
children,
|
children,
|
||||||
pageType,
|
pageType,
|
||||||
}: PropsWithChildren<MapProps>) {
|
}: PropsWithChildren<MapProps>) {
|
||||||
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const isMapView = useMemo(
|
const isMapView = useMemo(
|
||||||
() => searchParams.get("view") === "map",
|
() => searchParams.get("view") === "map",
|
||||||
@@ -91,7 +92,9 @@ export default function Map({
|
|||||||
}, [rootDiv, isMapView, handleMapHeight])
|
}, [rootDiv, isMapView, handleMapHeight])
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
window.history.pushState({}, "", window.location.pathname)
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.delete("view")
|
||||||
|
router.push(url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,24 +1,43 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||||
|
|
||||||
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||||
|
|
||||||
|
import { getCityHeadingText } from "../utils"
|
||||||
|
|
||||||
import styles from "./sidebarContentWrapper.module.css"
|
import styles from "./sidebarContentWrapper.module.css"
|
||||||
|
|
||||||
|
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
||||||
|
cityName?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function SidebarContentWrapper({
|
export default function SidebarContentWrapper({
|
||||||
|
cityName,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren) {
|
}: SidebarContentWrapperProps) {
|
||||||
|
const intl = useIntl()
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
useStickyPosition({
|
useStickyPosition({
|
||||||
ref: sidebarRef,
|
ref: sidebarRef,
|
||||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
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 (
|
return (
|
||||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||||
|
{headingText && <Title level="h2">{headingText}</Title>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
import { MapIcon } from "@/components/Icons"
|
|
||||||
import StaticMap from "@/components/Maps/StaticMap"
|
import StaticMap from "@/components/Maps/StaticMap"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import MapButton from "./MapButton"
|
||||||
|
|
||||||
import styles from "./staticMap.module.css"
|
import styles from "./staticMap.module.css"
|
||||||
|
|
||||||
interface StaticMapProps {
|
interface StaticMapProps {
|
||||||
@@ -32,19 +30,7 @@ export default async function DestinationStaticMap({
|
|||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
altText={altText}
|
altText={altText}
|
||||||
/>
|
/>
|
||||||
<Button
|
<MapButton className={styles.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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
components/ContentType/DestinationPage/utils.ts
Normal file
32
components/ContentType/DestinationPage/utils.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
41
components/HotelFilterAndSort/Filter/Checkbox/index.tsx
Normal file
41
components/HotelFilterAndSort/Filter/Checkbox/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
components/HotelFilterAndSort/Filter/filter.module.css
Normal file
27
components/HotelFilterAndSort/Filter/filter.module.css
Normal 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;
|
||||||
|
}
|
||||||
76
components/HotelFilterAndSort/Filter/index.tsx
Normal file
76
components/HotelFilterAndSort/Filter/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
components/HotelFilterAndSort/Sort/index.tsx
Normal file
34
components/HotelFilterAndSort/Sort/index.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
components/HotelFilterAndSort/hotelFilterAndSort.module.css
Normal file
58
components/HotelFilterAndSort/hotelFilterAndSort.module.css
Normal 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);
|
||||||
|
}
|
||||||
135
components/HotelFilterAndSort/index.tsx
Normal file
135
components/HotelFilterAndSort/index.tsx
Normal 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
5
contexts/HotelData.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
import type { HotelDataStore } from "@/types/contexts/hotel-data"
|
||||||
|
|
||||||
|
export const HotelDataContext = createContext<HotelDataStore | null>(null)
|
||||||
@@ -277,6 +277,8 @@
|
|||||||
"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 with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
|
||||||
"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",
|
||||||
|
|||||||
@@ -278,6 +278,8 @@
|
|||||||
"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 with {filter} in {cityName}": "Hotels mit {filter} in {cityName}",
|
||||||
"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",
|
||||||
|
|||||||
@@ -281,6 +281,8 @@
|
|||||||
"Hotels": "Hotels",
|
"Hotels": "Hotels",
|
||||||
"Hotels & Destinations": "Hotels & Destinations",
|
"Hotels & Destinations": "Hotels & Destinations",
|
||||||
"Hotels in {city}": "Hotels in {city}",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"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 with {filter} in {cityName}": "Hotellit, joissa on {filter} kaupungissa {cityName}",
|
||||||
"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",
|
||||||
|
|||||||
@@ -276,6 +276,8 @@
|
|||||||
"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 with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
|
||||||
"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",
|
||||||
|
|||||||
@@ -276,6 +276,8 @@
|
|||||||
"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 with {filter} in {cityName}": "Hotell med {filter} i {cityName}",
|
||||||
"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",
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ query GetDestinationCityPageMetadata($locale: String!, $uid: String!) {
|
|||||||
...Metadata
|
...Metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
destination_settings {
|
||||||
|
city_denmark
|
||||||
|
city_finland
|
||||||
|
city_germany
|
||||||
|
city_norway
|
||||||
|
city_poland
|
||||||
|
city_sweden
|
||||||
|
}
|
||||||
system {
|
system {
|
||||||
...System
|
...System
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,17 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
await getUidAndContentTypeByPath(incomingPathNameParts.join("/"))
|
await getUidAndContentTypeByPath(incomingPathNameParts.join("/"))
|
||||||
|
|
||||||
if (parentUid) {
|
if (parentUid) {
|
||||||
|
contentType = parentContentType
|
||||||
|
uid = parentUid
|
||||||
switch (parentContentType) {
|
switch (parentContentType) {
|
||||||
case PageContentTypeEnum.hotelPage:
|
case PageContentTypeEnum.hotelPage:
|
||||||
// E.g. Dedicated pages for restaurant, parking etc.
|
// E.g. Dedicated pages for restaurant, parking etc.
|
||||||
contentType = parentContentType
|
|
||||||
uid = parentUid
|
|
||||||
searchParams.set("subpage", subpage)
|
searchParams.set("subpage", subpage)
|
||||||
break
|
break
|
||||||
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
|
// E.g. Active filters inside destination pages to filter hotels.
|
||||||
|
searchParams.set("filterFromUrl", subpage)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -69,6 +69,7 @@
|
|||||||
"react-intl": "^6.6.8",
|
"react-intl": "^6.6.8",
|
||||||
"react-to-print": "^3.0.2",
|
"react-to-print": "^3.0.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"supercluster": "^8.0.1",
|
"supercluster": "^8.0.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
@@ -20956,6 +20957,14 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/snake-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"react-intl": "^6.6.8",
|
"react-intl": "^6.6.8",
|
||||||
"react-to-print": "^3.0.2",
|
"react-to-print": "^3.0.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"supercluster": "^8.0.1",
|
"supercluster": "^8.0.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
|||||||
65
providers/HotelDataProvider.tsx
Normal file
65
providers/HotelDataProvider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
server/routers/contentstack/metadata/input.ts
Normal file
5
server/routers/contentstack/metadata/input.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const getMetadataInput = z.object({
|
||||||
|
subpage: z.string().optional(),
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { attributesSchema as hotelAttributesSchema } from "../../hotels/schemas/hotel"
|
import { attributesSchema as hotelAttributesSchema } from "../../hotels/schemas/hotel"
|
||||||
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
|
||||||
|
import { systemSchema } from "../schemas/system"
|
||||||
import { getDescription, getImage, getTitle } from "./utils"
|
import { getDescription, getImage, getTitle } from "./utils"
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
@@ -61,6 +62,17 @@ export const rawMetadataSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.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(),
|
heading: z.string().optional().nullable(),
|
||||||
preamble: z.string().optional().nullable(),
|
preamble: z.string().optional().nullable(),
|
||||||
header: z
|
header: z
|
||||||
@@ -77,6 +89,10 @@ 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(),
|
||||||
|
cityFilter: z.string().optional().nullable(),
|
||||||
|
cityFilterType: z.enum(["facility", "surroundings"]).optional().nullable(),
|
||||||
|
system: systemSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const metadataSchema = rawMetadataSchema.transform(async (data) => {
|
export const metadataSchema = rawMetadataSchema.transform(async (data) => {
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
|
|||||||
import { generateTag } from "@/utils/generateTag"
|
import { generateTag } from "@/utils/generateTag"
|
||||||
|
|
||||||
import { getHotel } from "../../hotels/query"
|
import { getHotel } from "../../hotels/query"
|
||||||
|
import { getMetadataInput } from "./input"
|
||||||
import { metadataSchema } from "./output"
|
import { metadataSchema } from "./output"
|
||||||
import { affix } from "./utils"
|
import { affix, getCityData } 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"
|
||||||
@@ -117,76 +118,85 @@ async function getTransformedMetadata(data: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const metadataQueryRouter = router({
|
export const metadataQueryRouter = router({
|
||||||
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
|
get: contentStackUidWithServiceProcedure
|
||||||
const variables = {
|
.input(getMetadataInput)
|
||||||
lang: ctx.lang,
|
.query(async ({ ctx, input }) => {
|
||||||
uid: ctx.uid,
|
const variables = {
|
||||||
}
|
lang: ctx.lang,
|
||||||
|
uid: ctx.uid,
|
||||||
|
}
|
||||||
|
|
||||||
switch (ctx.contentType) {
|
switch (ctx.contentType) {
|
||||||
case PageContentTypeEnum.accountPage:
|
case PageContentTypeEnum.accountPage:
|
||||||
const accountPageResponse = await fetchMetadata<{
|
const accountPageResponse = await fetchMetadata<{
|
||||||
account_page: RawMetadataSchema
|
account_page: RawMetadataSchema
|
||||||
}>(GetAccountPageMetadata, variables)
|
}>(GetAccountPageMetadata, variables)
|
||||||
return getTransformedMetadata(accountPageResponse.account_page)
|
return getTransformedMetadata(accountPageResponse.account_page)
|
||||||
case PageContentTypeEnum.collectionPage:
|
case PageContentTypeEnum.collectionPage:
|
||||||
const collectionPageResponse = await fetchMetadata<{
|
const collectionPageResponse = await fetchMetadata<{
|
||||||
collection_page: RawMetadataSchema
|
collection_page: RawMetadataSchema
|
||||||
}>(GetCollectionPageMetadata, variables)
|
}>(GetCollectionPageMetadata, variables)
|
||||||
return getTransformedMetadata(collectionPageResponse.collection_page)
|
return getTransformedMetadata(collectionPageResponse.collection_page)
|
||||||
case PageContentTypeEnum.contentPage:
|
case PageContentTypeEnum.contentPage:
|
||||||
const contentPageResponse = await fetchMetadata<{
|
const contentPageResponse = await fetchMetadata<{
|
||||||
content_page: RawMetadataSchema
|
content_page: RawMetadataSchema
|
||||||
}>(GetContentPageMetadata, variables)
|
}>(GetContentPageMetadata, variables)
|
||||||
return getTransformedMetadata(contentPageResponse.content_page)
|
return getTransformedMetadata(contentPageResponse.content_page)
|
||||||
case PageContentTypeEnum.destinationOverviewPage:
|
case PageContentTypeEnum.destinationOverviewPage:
|
||||||
const destinationOverviewPageResponse = await fetchMetadata<{
|
const destinationOverviewPageResponse = await fetchMetadata<{
|
||||||
destination_overview_page: RawMetadataSchema
|
destination_overview_page: RawMetadataSchema
|
||||||
}>(GetDestinationOverviewPageMetadata, variables)
|
}>(GetDestinationOverviewPageMetadata, variables)
|
||||||
return getTransformedMetadata(
|
return getTransformedMetadata(
|
||||||
destinationOverviewPageResponse.destination_overview_page
|
destinationOverviewPageResponse.destination_overview_page
|
||||||
)
|
)
|
||||||
case PageContentTypeEnum.destinationCountryPage:
|
case PageContentTypeEnum.destinationCountryPage:
|
||||||
const destinationCountryPageResponse = await fetchMetadata<{
|
const destinationCountryPageResponse = await fetchMetadata<{
|
||||||
destination_country_page: RawMetadataSchema
|
destination_country_page: RawMetadataSchema
|
||||||
}>(GetDestinationCountryPageMetadata, variables)
|
}>(GetDestinationCountryPageMetadata, variables)
|
||||||
return getTransformedMetadata(
|
return getTransformedMetadata(
|
||||||
destinationCountryPageResponse.destination_country_page
|
destinationCountryPageResponse.destination_country_page
|
||||||
)
|
)
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
const destinationCityPageResponse = await fetchMetadata<{
|
const destinationCityPageResponse = await fetchMetadata<{
|
||||||
destination_city_page: RawMetadataSchema
|
destination_city_page: RawMetadataSchema
|
||||||
}>(GetDestinationCityPageMetadata, variables)
|
}>(GetDestinationCityPageMetadata, variables)
|
||||||
return getTransformedMetadata(
|
const cityData = await getCityData(
|
||||||
destinationCityPageResponse.destination_city_page
|
destinationCityPageResponse.destination_city_page,
|
||||||
)
|
input,
|
||||||
case PageContentTypeEnum.loyaltyPage:
|
ctx.serviceToken,
|
||||||
const loyaltyPageResponse = await fetchMetadata<{
|
ctx.lang
|
||||||
loyalty_page: RawMetadataSchema
|
)
|
||||||
}>(GetLoyaltyPageMetadata, variables)
|
return getTransformedMetadata({
|
||||||
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
|
...destinationCityPageResponse.destination_city_page,
|
||||||
case PageContentTypeEnum.hotelPage:
|
...cityData,
|
||||||
const hotelPageResponse = await fetchMetadata<{
|
})
|
||||||
hotel_page: RawMetadataSchema
|
case PageContentTypeEnum.loyaltyPage:
|
||||||
}>(GetHotelPageMetadata, variables)
|
const loyaltyPageResponse = await fetchMetadata<{
|
||||||
const hotelPageData = hotelPageResponse.hotel_page
|
loyalty_page: RawMetadataSchema
|
||||||
const hotelData = hotelPageData.hotel_page_id
|
}>(GetLoyaltyPageMetadata, variables)
|
||||||
? await getHotel(
|
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
|
||||||
{
|
case PageContentTypeEnum.hotelPage:
|
||||||
hotelId: hotelPageData.hotel_page_id,
|
const hotelPageResponse = await fetchMetadata<{
|
||||||
isCardOnlyPayment: false,
|
hotel_page: RawMetadataSchema
|
||||||
language: ctx.lang,
|
}>(GetHotelPageMetadata, variables)
|
||||||
},
|
const hotelPageData = hotelPageResponse.hotel_page
|
||||||
ctx.serviceToken
|
const hotelData = hotelPageData.hotel_page_id
|
||||||
)
|
? await getHotel(
|
||||||
: null
|
{
|
||||||
|
hotelId: hotelPageData.hotel_page_id,
|
||||||
|
isCardOnlyPayment: false,
|
||||||
|
language: ctx.lang,
|
||||||
|
},
|
||||||
|
ctx.serviceToken
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
return getTransformedMetadata({
|
return getTransformedMetadata({
|
||||||
...hotelPageData,
|
...hotelPageData,
|
||||||
hotelData: hotelData?.hotel,
|
hotelData: hotelData?.hotel,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import { getFiltersFromHotels } from "@/stores/hotel-data/helper"
|
||||||
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCityByCityIdentifier,
|
||||||
|
getHotelIdsByCityIdentifier,
|
||||||
|
getHotelsByHotelIds,
|
||||||
|
} from "../../hotels/utils"
|
||||||
|
|
||||||
import { RTETypeEnum } from "@/types/rte/enums"
|
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"
|
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) {
|
if (data.web?.breadcrumbs?.title) {
|
||||||
return data.web.breadcrumbs.title
|
return data.web.breadcrumbs.title
|
||||||
}
|
}
|
||||||
@@ -149,3 +182,64 @@ export function getImage(data: RawMetadataSchema) {
|
|||||||
}
|
}
|
||||||
return undefined
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import slugify from "slugify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
|
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
|
||||||
@@ -5,7 +6,7 @@ import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
|||||||
|
|
||||||
import { FacilityEnum } from "@/types/enums/facilities"
|
import { FacilityEnum } from "@/types/enums/facilities"
|
||||||
|
|
||||||
export const detailedFacilitySchema = z.object({
|
const rawDetailedFacilitySchema = z.object({
|
||||||
filter: nullableStringValidator,
|
filter: nullableStringValidator,
|
||||||
icon: nullableStringValidator,
|
icon: nullableStringValidator,
|
||||||
id: z.nativeEnum(FacilityEnum),
|
id: z.nativeEnum(FacilityEnum),
|
||||||
@@ -14,8 +15,23 @@ export const detailedFacilitySchema = z.object({
|
|||||||
sortOrder: z.number(),
|
sortOrder: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const detailedFacilitiesSchema = nullableArrayObjectValidator(
|
function transformDetailedFacility(
|
||||||
detailedFacilitySchema
|
data: z.output<typeof rawDetailedFacilitySchema>
|
||||||
).transform((facilities) =>
|
) {
|
||||||
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
|
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
118
stores/hotel-data/helper.ts
Normal 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
159
stores/hotel-data/index.ts
Normal 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)
|
||||||
|
}
|
||||||
17
types/components/hotelFilterAndSort.ts
Normal file
17
types/components/hotelFilterAndSort.ts
Normal 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[]
|
||||||
|
}
|
||||||
3
types/contexts/hotel-data.ts
Normal file
3
types/contexts/hotel-data.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { createHotelDataStore } from "@/stores/hotel-data"
|
||||||
|
|
||||||
|
export type HotelDataStore = ReturnType<typeof createHotelDataStore>
|
||||||
5
types/enums/hotelFilterAndSort.ts
Normal file
5
types/enums/hotelFilterAndSort.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum SortOption {
|
||||||
|
Distance = "distance",
|
||||||
|
Name = "name",
|
||||||
|
TripAdvisorRating = "tripadvisor",
|
||||||
|
}
|
||||||
8
types/providers/hotel-data.ts
Normal file
8
types/providers/hotel-data.ts
Normal 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[]
|
||||||
|
}
|
||||||
46
types/stores/hotel-data.ts
Normal file
46
types/stores/hotel-data.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 RawMetadataSchema extends z.output<typeof rawMetadataSchema> {}
|
||||||
|
|
||||||
|
export interface MetadataInputSchema extends z.input<typeof getMetadataInput> {}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import type {
|
||||||
return await serverClient().contentstack.metadata.get()
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user