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({
|
||||
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()
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { 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,26 +48,26 @@ export default async function DestinationCityPage() {
|
||||
destination_settings,
|
||||
} = destinationCityPage
|
||||
|
||||
preloadHotels(cityIdentifier)
|
||||
preload(cityIdentifier)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<DestinationCityPageSkeleton />}>
|
||||
<HotelDataContainer
|
||||
cityIdentifier={cityIdentifier}
|
||||
filterFromUrl={filterFromUrl}
|
||||
>
|
||||
<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>
|
||||
<HotelListing />
|
||||
{blocks && <Blocks blocks={blocks} />}
|
||||
</main>
|
||||
<aside className={styles.sidebar}>
|
||||
<SidebarContentWrapper>
|
||||
<Title level="h2">{heading}</Title>
|
||||
<SidebarContentWrapper cityName={city.name}>
|
||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||
<ExperienceList experiences={experiences} />
|
||||
{has_sidepeek && (
|
||||
@@ -77,7 +83,13 @@ export default async function DestinationCityPage() {
|
||||
</SidebarContentWrapper>
|
||||
</aside>
|
||||
</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}>
|
||||
<TrackingSDK pageData={tracking} />
|
||||
</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 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"
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 { 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) => {
|
||||
|
||||
@@ -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,7 +118,9 @@ async function getTransformedMetadata(data: unknown) {
|
||||
}
|
||||
|
||||
export const metadataQueryRouter = router({
|
||||
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
|
||||
get: contentStackUidWithServiceProcedure
|
||||
.input(getMetadataInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const variables = {
|
||||
lang: ctx.lang,
|
||||
uid: ctx.uid,
|
||||
@@ -157,9 +160,16 @@ export const metadataQueryRouter = router({
|
||||
const destinationCityPageResponse = await fetchMetadata<{
|
||||
destination_city_page: RawMetadataSchema
|
||||
}>(GetDestinationCityPageMetadata, variables)
|
||||
return getTransformedMetadata(
|
||||
destinationCityPageResponse.destination_city_page
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
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 MetadataInputSchema extends z.input<typeof getMetadataInput> {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user