Merged in feat/SW-1451-country-page-sorting (pull request #1426)
Feat/SW-1451 country page filtering and sorting * feat(SW-1451): implemented sorting and filtering on country pages * feat(SW-1451): Renamed hotel-data to destination-data because of its multi-purpose use * feat(SW-1451): Now filtering after change of url instead of inside the store after submit Approved-by: Fredrik Thorsson
This commit is contained in:
@@ -64,8 +64,7 @@ export default async function ContentTypePage({
|
|||||||
case PageContentTypeEnum.destinationCountryPage:
|
case PageContentTypeEnum.destinationCountryPage:
|
||||||
return <DestinationCountryPage />
|
return <DestinationCountryPage />
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
const filterFromUrl = searchParams.filterFromUrl
|
return <DestinationCityPage />
|
||||||
return <DestinationCityPage filterFromUrl={filterFromUrl} />
|
|
||||||
case PageContentTypeEnum.hotelPage:
|
case PageContentTypeEnum.hotelPage:
|
||||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
return notFound()
|
return notFound()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
getDestinationCityPagesByCountry,
|
||||||
|
getHotelsByCountry,
|
||||||
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||||
|
|
||||||
|
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||||
|
import type { Country } from "@/types/enums/country"
|
||||||
|
import { SortOption } from "@/types/enums/destinationFilterAndSort"
|
||||||
|
|
||||||
|
interface CityDataContainerProps extends React.PropsWithChildren {
|
||||||
|
country: Country
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preload(country: Country) {
|
||||||
|
void getHotelsByCountry(country)
|
||||||
|
void getDestinationCityPagesByCountry(country)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CityDataContainer({
|
||||||
|
country,
|
||||||
|
children,
|
||||||
|
}: CityDataContainerProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const [hotels, cities] = await Promise.all([
|
||||||
|
getHotelsByCountry(country),
|
||||||
|
getDestinationCityPagesByCountry(country),
|
||||||
|
])
|
||||||
|
|
||||||
|
const sortItems: SortItem[] = [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: "Recommended" }),
|
||||||
|
value: SortOption.Recommended,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DestinationDataProvider
|
||||||
|
allHotels={hotels}
|
||||||
|
allCities={cities}
|
||||||
|
sortItems={sortItems}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DestinationDataProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
|
|
||||||
@@ -5,7 +7,7 @@ import ExperienceListSkeleton from "../../ExperienceList/ExperienceListSkeleton"
|
|||||||
|
|
||||||
import styles from "./cityListingItem.module.css"
|
import styles from "./cityListingItem.module.css"
|
||||||
|
|
||||||
export default async function CityListingItemSkeleton() {
|
export default function CityListingItemSkeleton() {
|
||||||
return (
|
return (
|
||||||
<article className={styles.container}>
|
<article className={styles.container}>
|
||||||
<div className={styles.imageWrapper}>
|
<div className={styles.imageWrapper}>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
import ExperienceList from "../../ExperienceList"
|
import ExperienceList from "../../ExperienceList"
|
||||||
@@ -18,8 +20,8 @@ interface CityListingItemProps {
|
|||||||
city: DestinationCityListItem
|
city: DestinationCityListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CityListingItem({ city }: CityListingItemProps) {
|
export default function CityListingItem({ city }: CityListingItemProps) {
|
||||||
const intl = await getIntl()
|
const intl = useIntl()
|
||||||
const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
|
const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
|
import CityListingItemSkeleton from "./CityListingItem/CityListingItemSkeleton"
|
||||||
|
|
||||||
import styles from "./cityListing.module.css"
|
import styles from "./cityListing.module.css"
|
||||||
|
|
||||||
export default async function CityListingSkeleton() {
|
export default function CityListingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
<div>
|
<div className={styles.listHeader}>
|
||||||
<SkeletonShimmer height="30px" width="300px" />
|
<SkeletonShimmer height="30px" width="200px" />
|
||||||
|
<SkeletonShimmer height="30px" width="120px" />
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.cityList}>
|
<ul className={styles.cityList}>
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
.container {
|
.container {
|
||||||
|
--scroll-margin-top: calc(
|
||||||
|
var(--booking-widget-mobile-height) + var(--Spacing-x2)
|
||||||
|
);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
|
scroll-margin-top: var(--scroll-margin-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cityList {
|
.cityList {
|
||||||
@@ -8,3 +17,15 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cityList:not(.allVisible) li:nth-child(n + 6) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
--scroll-margin-top: calc(
|
||||||
|
var(--booking-widget-desktop-height) + var(--Spacing-x2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,83 @@
|
|||||||
import { getDestinationCityPagesByCountry } from "@/lib/trpc/memoizedRequests"
|
"use client"
|
||||||
|
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
|
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { getIntl } from "@/i18n"
|
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||||
|
|
||||||
import CityListingItem from "./CityListingItem"
|
import CityListingItem from "./CityListingItem"
|
||||||
|
import CityListingSkeleton from "./CityListingSkeleton"
|
||||||
|
|
||||||
import styles from "./cityListing.module.css"
|
import styles from "./cityListing.module.css"
|
||||||
|
|
||||||
import type { Country } from "@/types/enums/country"
|
export default function CityListing() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
|
threshold: 300,
|
||||||
|
elementRef: scrollRef,
|
||||||
|
})
|
||||||
|
const { activeCities, filters, sortItems, isLoading } =
|
||||||
|
useDestinationDataStore((state) => ({
|
||||||
|
activeCities: state.activeCities,
|
||||||
|
filters: state.allFilters,
|
||||||
|
sortItems: state.sortItems,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
}))
|
||||||
|
const [allCitiesVisible, setAllCitiesVisible] = useState(
|
||||||
|
activeCities.length <= 5
|
||||||
|
)
|
||||||
|
|
||||||
interface CityListingProps {
|
function handleShowMore() {
|
||||||
country: Country
|
if (scrollRef.current && allCitiesVisible) {
|
||||||
}
|
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
export default async function CityListing({ country }: CityListingProps) {
|
setAllCitiesVisible((state) => !state)
|
||||||
const intl = await getIntl()
|
|
||||||
const cities = await getDestinationCityPagesByCountry(country)
|
|
||||||
|
|
||||||
if (!cities.length) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return isLoading ? (
|
||||||
<section className={styles.container}>
|
<CityListingSkeleton />
|
||||||
<div>
|
) : (
|
||||||
|
<section className={styles.container} ref={scrollRef}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
<Subtitle type="two">
|
<Subtitle type="two">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: `{count, plural, one {{count} Location} other {{count} Locations}}`,
|
id: `{count, plural, one {{count} Location} other {{count} Locations}}`,
|
||||||
},
|
},
|
||||||
{ count: cities.length }
|
{ count: activeCities.length }
|
||||||
)}
|
)}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
|
<DestinationFilterAndSort
|
||||||
|
filters={filters}
|
||||||
|
sortItems={sortItems}
|
||||||
|
listType="city"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.cityList}>
|
<ul
|
||||||
{cities.map((city) => (
|
className={`${styles.cityList} ${allCitiesVisible ? styles.allVisible : ""}`}
|
||||||
|
>
|
||||||
|
{activeCities.map((city) => (
|
||||||
<li key={city.system.uid}>
|
<li key={city.system.uid}>
|
||||||
<CityListingItem city={city} />
|
<CityListingItem city={city} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{activeCities.length > 5 ? (
|
||||||
|
<ShowMoreButton
|
||||||
|
loadMoreData={handleShowMore}
|
||||||
|
showLess={allCitiesVisible}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showBackToTop && (
|
||||||
|
<BackToTopButton position="center" onClick={scrollToTop} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
|
import HotelListItemSkeleton from "../HotelListItem/HotelListItemSkeleton"
|
||||||
|
|
||||||
|
import styles from "./hotelList.module.css"
|
||||||
|
|
||||||
|
export default function HotelListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={styles.hotelListWrapper}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<SkeletonShimmer height="30px" width="200px" />
|
||||||
|
<SkeletonShimmer height="30px" width="120px" />
|
||||||
|
</div>
|
||||||
|
<ul className={styles.hotelList}>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<HotelListItemSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps"
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import { debounce } from "@/utils/debounce"
|
import { debounce } from "@/utils/debounce"
|
||||||
|
|
||||||
import HotelListItem from "../HotelListItem"
|
import HotelListItem from "../HotelListItem"
|
||||||
|
import HotelListSkeleton from "./HotelListSkeleton"
|
||||||
import { getVisibleHotels } from "./utils"
|
import { getVisibleHotels } from "./utils"
|
||||||
|
|
||||||
import styles from "./hotelList.module.css"
|
import styles from "./hotelList.module.css"
|
||||||
@@ -22,11 +23,13 @@ export default function HotelList() {
|
|||||||
const map = useMap()
|
const map = useMap()
|
||||||
const coreLib = useMapsLibrary("core")
|
const coreLib = useMapsLibrary("core")
|
||||||
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
|
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
|
||||||
const { filters, sortItems, activeHotels } = useHotelDataStore((state) => ({
|
const { filters, sortItems, activeHotels, isLoading } =
|
||||||
filters: state.allFilters,
|
useDestinationDataStore((state) => ({
|
||||||
sortItems: state.sortItems,
|
filters: state.allFilters,
|
||||||
activeHotels: state.activeHotels,
|
sortItems: state.sortItems,
|
||||||
}))
|
activeHotels: state.activeHotels,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
}))
|
||||||
|
|
||||||
const debouncedUpdateVisibleHotels = useMemo(
|
const debouncedUpdateVisibleHotels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -51,7 +54,9 @@ export default function HotelList() {
|
|||||||
}
|
}
|
||||||
}, [map, coreLib, debouncedUpdateVisibleHotels])
|
}, [map, coreLib, debouncedUpdateVisibleHotels])
|
||||||
|
|
||||||
return (
|
return isLoading ? (
|
||||||
|
<HotelListSkeleton />
|
||||||
|
) : (
|
||||||
<div className={styles.hotelListWrapper}>
|
<div className={styles.hotelListWrapper}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<Body>
|
<Body>
|
||||||
@@ -60,7 +65,11 @@ export default function HotelList() {
|
|||||||
{ count: visibleHotels.length }
|
{ count: visibleHotels.length }
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
|
<DestinationFilterAndSort
|
||||||
|
filters={filters}
|
||||||
|
sortItems={sortItems}
|
||||||
|
listType="hotel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.hotelList}>
|
<ul className={styles.hotelList}>
|
||||||
{visibleHotels.map(({ hotel, url }) => (
|
{visibleHotels.map(({ hotel, url }) => (
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
|
import styles from "./hotelListItem.module.css"
|
||||||
|
|
||||||
|
export default function HotelListItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<article className={styles.hotelListItem}>
|
||||||
|
<div className={styles.imageWrapper}>
|
||||||
|
<SkeletonShimmer width="100%" height="100%" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.intro}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<SkeletonShimmer height="24px" width="100px" />
|
||||||
|
</div>
|
||||||
|
<SkeletonShimmer height="35px" width="300px" />
|
||||||
|
<div className={styles.captions}>
|
||||||
|
<SkeletonShimmer height="20px" width="130px" />
|
||||||
|
<SkeletonShimmer height="20px" width="180px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className={styles.amenityList}>
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<li className={styles.amenityItem} key={index}>
|
||||||
|
<SkeletonShimmer height="20px" width="40px" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className={styles.ctaWrapper}>
|
||||||
|
<SkeletonShimmer height="45px" width="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,8 +11,25 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||||
align-content: start;
|
align-content: flex-start;
|
||||||
justify-items: start;
|
justify-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageWrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tripAdvisor {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Spacing-x-half);
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
padding: var(--Spacing-x-quarter) var(--Spacing-x1);
|
||||||
|
border-radius: var(--Corner-radius-Small);
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro {
|
.intro {
|
||||||
@@ -48,7 +65,11 @@
|
|||||||
.hotelListItem {
|
.hotelListItem {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
grid-template-columns: 1fr 2fr;
|
grid-template-columns: 160px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageWrapper {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
|
import { TripAdvisorIcon } from "@/components/Icons"
|
||||||
import HotelLogo from "@/components/Icons/Logos"
|
import HotelLogo from "@/components/Icons/Logos"
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -29,13 +30,25 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.hotelListItem}>
|
<article className={styles.hotelListItem}>
|
||||||
<ImageGallery
|
<div className={styles.imageWrapper}>
|
||||||
images={galleryImages}
|
<ImageGallery
|
||||||
title={intl.formatMessage(
|
images={galleryImages}
|
||||||
{ id: "{title} - Image gallery" },
|
fill
|
||||||
{ title: hotel.name }
|
sizes="(min-width: 768px) 350px, 100vw"
|
||||||
|
title={intl.formatMessage(
|
||||||
|
{ id: "{title} - Image gallery" },
|
||||||
|
{ title: hotel.name }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{hotel.ratings?.tripAdvisor.rating && (
|
||||||
|
<div className={styles.tripAdvisor}>
|
||||||
|
<TripAdvisorIcon color="burgundy" />
|
||||||
|
<Caption color="burgundy">
|
||||||
|
{hotel.ratings.tripAdvisor.rating}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.intro}>
|
<div className={styles.intro}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
|
||||||
import Map from "../../Map"
|
import Map from "../../Map"
|
||||||
import { getCityHeadingText } from "../../utils"
|
import { getHeadingText } from "../../utils"
|
||||||
import HotelList from "./HotelList"
|
import HotelList from "./HotelList"
|
||||||
|
|
||||||
import styles from "./cityMap.module.css"
|
import styles from "./cityMap.module.css"
|
||||||
@@ -21,7 +21,7 @@ interface CityMapProps {
|
|||||||
|
|
||||||
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
|
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { activeHotels, allFilters, activeFilters } = useHotelDataStore(
|
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
activeHotels: state.activeHotels,
|
activeHotels: state.activeHotels,
|
||||||
allFilters: state.allFilters,
|
allFilters: state.allFilters,
|
||||||
@@ -37,7 +37,7 @@ export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
|
|||||||
textTransform="regular"
|
textTransform="regular"
|
||||||
className={styles.title}
|
className={styles.title}
|
||||||
>
|
>
|
||||||
{getCityHeadingText(intl, city.name, allFilters, activeFilters[0])}
|
{getHeadingText(intl, city.name, allFilters, activeFilters[0])}
|
||||||
</Title>
|
</Title>
|
||||||
<HotelList />
|
<HotelList />
|
||||||
</Map>
|
</Map>
|
||||||
|
|||||||
@@ -23,13 +23,7 @@ import styles from "./destinationCityPage.module.css"
|
|||||||
|
|
||||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||||
|
|
||||||
interface DestinationCityPageProps {
|
export default async function DestinationCityPage() {
|
||||||
filterFromUrl: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function DestinationCityPage({
|
|
||||||
filterFromUrl,
|
|
||||||
}: DestinationCityPageProps) {
|
|
||||||
const pageData = await getDestinationCityPage()
|
const pageData = await getDestinationCityPage()
|
||||||
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
@@ -53,10 +47,7 @@ export default async function DestinationCityPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Suspense fallback={<DestinationCityPageSkeleton />}>
|
<Suspense fallback={<DestinationCityPageSkeleton />}>
|
||||||
<HotelDataContainer
|
<HotelDataContainer cityIdentifier={cityIdentifier}>
|
||||||
cityIdentifier={cityIdentifier}
|
|
||||||
filterFromUrl={filterFromUrl}
|
|
||||||
>
|
|
||||||
<div className={styles.pageContainer}>
|
<div className={styles.pageContainer}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
||||||
@@ -67,7 +58,7 @@ export default async function DestinationCityPage({
|
|||||||
{blocks && <Blocks blocks={blocks} />}
|
{blocks && <Blocks blocks={blocks} />}
|
||||||
</main>
|
</main>
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
<SidebarContentWrapper cityName={city.name}>
|
<SidebarContentWrapper location={city.name}>
|
||||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||||
<ExperienceList experiences={experiences} />
|
<ExperienceList experiences={experiences} />
|
||||||
{has_sidepeek && (
|
{has_sidepeek && (
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
|
import CityListItemSkeleton from "../CityListItem/CityListItemSkeleton"
|
||||||
|
|
||||||
|
import styles from "./cityList.module.css"
|
||||||
|
|
||||||
|
export default function CityListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={styles.cityListWrapper}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<SkeletonShimmer height="30px" width="200px" />
|
||||||
|
<SkeletonShimmer height="30px" width="120px" />
|
||||||
|
</div>
|
||||||
|
<ul className={styles.cityList}>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<CityListItemSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,31 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import CityListItem from "../CityListItem"
|
import CityListItem from "../CityListItem"
|
||||||
|
import CityListSkeleton from "./CityListSkeleton"
|
||||||
|
|
||||||
import styles from "./cityList.module.css"
|
import styles from "./cityList.module.css"
|
||||||
|
|
||||||
import type { DestinationCityListItem } from "@/types/trpc/routers/contentstack/destinationCityPage"
|
export default function CityList() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { filters, sortItems, activeCities, isLoading } =
|
||||||
|
useDestinationDataStore((state) => ({
|
||||||
|
filters: state.allFilters,
|
||||||
|
sortItems: state.sortItems,
|
||||||
|
activeCities: state.activeCities,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
}))
|
||||||
|
|
||||||
interface CityListProps {
|
return isLoading ? (
|
||||||
cities: DestinationCityListItem[]
|
<CityListSkeleton />
|
||||||
}
|
) : (
|
||||||
|
|
||||||
export default async function CityList({ cities }: CityListProps) {
|
|
||||||
const intl = await getIntl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.cityListWrapper}>
|
<div className={styles.cityListWrapper}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<Body>
|
<Body>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "{count} destinations" },
|
{ id: "{count} destinations" },
|
||||||
{ count: cities.length }
|
{ count: activeCities.length }
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
|
<DestinationFilterAndSort
|
||||||
|
filters={filters}
|
||||||
|
sortItems={sortItems}
|
||||||
|
listType="city"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.cityList}>
|
<ul className={styles.cityList}>
|
||||||
{cities.map((city) => (
|
{activeCities.map((city) => (
|
||||||
<li key={city.system.uid}>
|
<li key={city.system.uid}>
|
||||||
<CityListItem city={city} />
|
<CityListItem city={city} />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
|
import ExperienceListSkeleton from "../../../ExperienceList/ExperienceListSkeleton"
|
||||||
|
|
||||||
|
import styles from "./cityListItem.module.css"
|
||||||
|
|
||||||
|
export default function CityListItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<article className={styles.cityListItem}>
|
||||||
|
<div className={styles.imageWrapper}>
|
||||||
|
<SkeletonShimmer width="100%" height="100%" />
|
||||||
|
</div>
|
||||||
|
<section className={styles.content}>
|
||||||
|
<SkeletonShimmer height="52px" />
|
||||||
|
<div className={styles.experienceList}>
|
||||||
|
<ExperienceListSkeleton />
|
||||||
|
</div>
|
||||||
|
<div className={styles.ctaWrapper}>
|
||||||
|
<SkeletonShimmer height="45px" width="100%" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
import ExperienceList from "../../../ExperienceList"
|
import ExperienceList from "../../../ExperienceList"
|
||||||
@@ -16,8 +18,8 @@ interface CityListItemProps {
|
|||||||
city: DestinationCityListItem
|
city: DestinationCityListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CityListItem({ city }: CityListItemProps) {
|
export default function CityListItem({ city }: CityListItemProps) {
|
||||||
const intl = await getIntl()
|
const intl = useIntl()
|
||||||
const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
|
const galleryImages = mapImageVaultImagesToGalleryImages(city.images)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,50 +1,48 @@
|
|||||||
import { env } from "@/env/server"
|
"use client"
|
||||||
import {
|
|
||||||
getDestinationCityPagesByCountry,
|
import { useIntl } from "react-intl"
|
||||||
getHotelsByCountry,
|
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import Map from "../../Map"
|
import Map from "../../Map"
|
||||||
|
import { getHeadingText } from "../../utils"
|
||||||
import CityList from "./CityList"
|
import CityList from "./CityList"
|
||||||
|
|
||||||
import styles from "./countryMap.module.css"
|
import styles from "./countryMap.module.css"
|
||||||
|
|
||||||
import type { Country } from "@/types/enums/country"
|
|
||||||
|
|
||||||
interface CountryMapProps {
|
interface CountryMapProps {
|
||||||
country: Country
|
mapId: string
|
||||||
|
apiKey: string
|
||||||
|
country: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preload(country: Country) {
|
export default function CountryMap({
|
||||||
void getHotelsByCountry(country)
|
mapId,
|
||||||
void getDestinationCityPagesByCountry(country)
|
apiKey,
|
||||||
}
|
country,
|
||||||
export default async function CountryMap({ country }: CountryMapProps) {
|
}: CountryMapProps) {
|
||||||
const intl = await getIntl()
|
const intl = useIntl()
|
||||||
const [hotels, cities] = await Promise.all([
|
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
|
||||||
getHotelsByCountry(country),
|
(state) => ({
|
||||||
getDestinationCityPagesByCountry(country),
|
activeHotels: state.activeHotels,
|
||||||
])
|
allFilters: state.allFilters,
|
||||||
|
activeFilters: state.activeFilters,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="country">
|
||||||
hotels={hotels}
|
|
||||||
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
|
||||||
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
|
||||||
pageType="country"
|
|
||||||
>
|
|
||||||
<Title
|
<Title
|
||||||
level="h2"
|
level="h2"
|
||||||
as="h3"
|
as="h3"
|
||||||
textTransform="regular"
|
textTransform="regular"
|
||||||
className={styles.title}
|
className={styles.title}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: `Destinations in {country}` }, { country })}
|
{getHeadingText(intl, country, allFilters, activeFilters[0])}
|
||||||
</Title>
|
</Title>
|
||||||
<CityList cities={cities} />
|
<CityList />
|
||||||
</Map>
|
</Map>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
|
|
||||||
|
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
||||||
|
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||||
|
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||||
|
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
||||||
|
|
||||||
|
import styles from "./destinationCountryPage.module.css"
|
||||||
|
|
||||||
|
export default function DestinationCountryPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={styles.pageContainer}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<BreadcrumbsSkeleton />
|
||||||
|
<TopImagesSkeleton />
|
||||||
|
</header>
|
||||||
|
<main className={styles.mainContent}>
|
||||||
|
<CityListingSkeleton />
|
||||||
|
</main>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<SidebarContentWrapperSkeleton>
|
||||||
|
<div>
|
||||||
|
<SkeletonShimmer height="20px" width="90%" />
|
||||||
|
<SkeletonShimmer height="20px" width="70%" />
|
||||||
|
<SkeletonShimmer height="20px" width="100%" />
|
||||||
|
<SkeletonShimmer height="20px" width="40%" />
|
||||||
|
</div>
|
||||||
|
<ExperienceListSkeleton />
|
||||||
|
<div>
|
||||||
|
<SkeletonShimmer height="200px" width="100%" />
|
||||||
|
</div>
|
||||||
|
</SidebarContentWrapperSkeleton>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
|
import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
|
||||||
|
|
||||||
|
export function getHeadingText(
|
||||||
|
intl: IntlShape,
|
||||||
|
location: string,
|
||||||
|
allFilters: CategorizedFilters,
|
||||||
|
filter?: string
|
||||||
|
) {
|
||||||
|
if (filter) {
|
||||||
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if (facilityFilter) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{ id: "Hotels with {filter} in {location}" },
|
||||||
|
{ location, filter: facilityFilter.name }
|
||||||
|
)
|
||||||
|
} else if (surroudingsFilter) {
|
||||||
|
return intl.formatMessage(
|
||||||
|
{ id: "Hotels near {filter} in {location}" },
|
||||||
|
{ location, filter: surroudingsFilter.name }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intl.formatMessage({ id: "Hotels in {location}" }, { location })
|
||||||
|
}
|
||||||
@@ -30,13 +30,6 @@
|
|||||||
background-color: var(--Base-Surface-Subtle-Normal);
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.experienceList {
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Spacing-x1);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.pageContainer {
|
.pageContainer {
|
||||||
max-width: var(--max-width-page);
|
max-width: var(--max-width-page);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests"
|
import { getDestinationCountryPage } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import Blocks from "@/components/Blocks"
|
import Blocks from "@/components/Blocks"
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
|
||||||
import TrackingSDK from "@/components/TrackingSDK"
|
import TrackingSDK from "@/components/TrackingSDK"
|
||||||
|
|
||||||
|
import CityDataContainer, { preload } from "../CityDataContainer"
|
||||||
import CityListing from "../CityListing"
|
import CityListing from "../CityListing"
|
||||||
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
|
||||||
import ExperienceList from "../ExperienceList"
|
import ExperienceList from "../ExperienceList"
|
||||||
|
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||||
import DestinationPageSidePeek from "../Sidepeek"
|
import DestinationPageSidePeek from "../Sidepeek"
|
||||||
import StaticMap from "../StaticMap"
|
import StaticMap from "../StaticMap"
|
||||||
import TopImages from "../TopImages"
|
import TopImages from "../TopImages"
|
||||||
import CountryMap, { preload } from "./CountryMap"
|
import CountryMap from "./CountryMap"
|
||||||
import SidebarContentWrapper from "./SidebarContentWrapper"
|
import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
|
||||||
|
|
||||||
import styles from "./destinationCountryPage.module.css"
|
import styles from "./destinationCountryPage.module.css"
|
||||||
|
|
||||||
@@ -33,7 +34,6 @@ export default async function DestinationCountryPage() {
|
|||||||
const {
|
const {
|
||||||
blocks,
|
blocks,
|
||||||
images,
|
images,
|
||||||
heading,
|
|
||||||
preamble,
|
preamble,
|
||||||
experiences,
|
experiences,
|
||||||
has_sidepeek,
|
has_sidepeek,
|
||||||
@@ -46,37 +46,44 @@ export default async function DestinationCountryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.pageContainer}>
|
<Suspense fallback={<DestinationCountryPageSkeleton />}>
|
||||||
<header className={styles.header}>
|
<CityDataContainer country={destination_settings.country}>
|
||||||
<Suspense fallback={<BreadcrumbsSkeleton />}>
|
<div className={styles.pageContainer}>
|
||||||
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
<header className={styles.header}>
|
||||||
</Suspense>
|
<Suspense fallback={<BreadcrumbsSkeleton />}>
|
||||||
<TopImages images={images} destinationName={translatedCountry} />
|
<Breadcrumbs
|
||||||
</header>
|
variant={PageContentTypeEnum.destinationCityPage}
|
||||||
<main className={styles.mainContent}>
|
/>
|
||||||
<Suspense fallback={<CityListingSkeleton />}>
|
</Suspense>
|
||||||
<CityListing country={destination_settings.country} />
|
<TopImages images={images} destinationName={translatedCountry} />
|
||||||
</Suspense>
|
</header>
|
||||||
{blocks && <Blocks blocks={blocks} />}
|
<main className={styles.mainContent}>
|
||||||
</main>
|
<CityListing />
|
||||||
<aside className={styles.sidebar}>
|
{blocks && <Blocks blocks={blocks} />}
|
||||||
<SidebarContentWrapper>
|
</main>
|
||||||
<Title level="h2">{heading}</Title>
|
<aside className={styles.sidebar}>
|
||||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
<SidebarContentWrapper location={translatedCountry}>
|
||||||
<ExperienceList experiences={experiences} />
|
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||||
{has_sidepeek && (
|
<ExperienceList experiences={experiences} />
|
||||||
<DestinationPageSidePeek
|
{has_sidepeek && (
|
||||||
buttonText={sidepeek_button_text}
|
<DestinationPageSidePeek
|
||||||
sidePeekContent={sidepeek_content}
|
buttonText={sidepeek_button_text}
|
||||||
/>
|
sidePeekContent={sidepeek_content}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<StaticMap country={destination_settings.country} />
|
<StaticMap country={destination_settings.country} />
|
||||||
</SidebarContentWrapper>
|
</SidebarContentWrapper>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<CountryMap country={destination_settings.country} />
|
<CountryMap
|
||||||
<TrackingSDK pageData={tracking} />
|
country={translatedCountry}
|
||||||
|
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
||||||
|
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
||||||
|
/>
|
||||||
|
</CityDataContainer>
|
||||||
|
</Suspense>
|
||||||
|
<TrackingSDK pageData={tracking} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import Chip from "@/components/TempDesignSystem/Chip"
|
import Chip from "@/components/TempDesignSystem/Chip"
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import { mapExperiencesToListData } from "./utils"
|
import { mapExperiencesToListData } from "./utils"
|
||||||
|
|
||||||
@@ -9,10 +12,8 @@ interface ExperienceListProps {
|
|||||||
experiences: string[]
|
experiences: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ExperienceList({
|
export default function ExperienceList({ experiences }: ExperienceListProps) {
|
||||||
experiences,
|
const intl = useIntl()
|
||||||
}: ExperienceListProps) {
|
|
||||||
const intl = await getIntl()
|
|
||||||
const experienceList = mapExperiencesToListData(experiences, intl)
|
const experienceList = mapExperiencesToListData(experiences, intl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import HotelDataProvider from "@/providers/HotelDataProvider"
|
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||||
|
|
||||||
import type { SortItem } from "@/types/components/hotelFilterAndSort"
|
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||||
import { SortOption } from "@/types/enums/hotelFilterAndSort"
|
import { SortOption } from "@/types/enums/destinationFilterAndSort"
|
||||||
|
|
||||||
interface HotelDataContainerProps extends React.PropsWithChildren {
|
interface HotelDataContainerProps extends React.PropsWithChildren {
|
||||||
cityIdentifier: string
|
cityIdentifier: string
|
||||||
filterFromUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preload(cityIdentifier: string) {
|
export function preload(cityIdentifier: string) {
|
||||||
@@ -17,7 +16,6 @@ export function preload(cityIdentifier: string) {
|
|||||||
|
|
||||||
export default async function HotelDataContainer({
|
export default async function HotelDataContainer({
|
||||||
cityIdentifier,
|
cityIdentifier,
|
||||||
filterFromUrl,
|
|
||||||
children,
|
children,
|
||||||
}: HotelDataContainerProps) {
|
}: HotelDataContainerProps) {
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
@@ -27,6 +25,7 @@ export default async function HotelDataContainer({
|
|||||||
{
|
{
|
||||||
label: intl.formatMessage({ id: "Distance to city center" }),
|
label: intl.formatMessage({ id: "Distance to city center" }),
|
||||||
value: SortOption.Distance,
|
value: SortOption.Distance,
|
||||||
|
isDefault: true,
|
||||||
},
|
},
|
||||||
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
|
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
|
||||||
{
|
{
|
||||||
@@ -36,12 +35,8 @@ export default async function HotelDataContainer({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotelDataProvider
|
<DestinationDataProvider allHotels={hotels} sortItems={sortItems}>
|
||||||
allHotels={hotels}
|
|
||||||
filterFromUrl={filterFromUrl}
|
|
||||||
sortItems={sortItems}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</HotelDataProvider>
|
</DestinationDataProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
.imageWrapper {
|
.imageWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tripAdvisor {
|
.tripAdvisor {
|
||||||
@@ -64,6 +65,10 @@
|
|||||||
grid-template-columns: minmax(250px, 350px) auto;
|
grid-template-columns: minmax(250px, 350px) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.imageWrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.ctaWrapper {
|
.ctaWrapper {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default function HotelListingItem({
|
|||||||
<div className={styles.imageWrapper}>
|
<div className={styles.imageWrapper}>
|
||||||
<ImageGallery
|
<ImageGallery
|
||||||
images={galleryImages}
|
images={galleryImages}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 768px) 350px, 100vw"
|
||||||
title={intl.formatMessage(
|
title={intl.formatMessage(
|
||||||
{ id: "{title} - Image gallery" },
|
{ id: "{title} - Image gallery" },
|
||||||
{ title: hotel.name }
|
{ title: hotel.name }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function HotelListingSkeleton() {
|
|||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
<div className={styles.listHeader}>
|
<div className={styles.listHeader}>
|
||||||
<SkeletonShimmer height="30px" width="300px" />
|
<SkeletonShimmer height="30px" width="300px" />
|
||||||
|
<SkeletonShimmer height="30px" width="100px" />
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.hotelList}>
|
<ul className={styles.hotelList}>
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
|||||||
@@ -1,52 +1,39 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
|
||||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import useHash from "@/hooks/useHash"
|
|
||||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||||
|
|
||||||
import HotelListingItem from "./HotelListingItem"
|
import HotelListingItem from "./HotelListingItem"
|
||||||
|
import HotelListingSkeleton from "./HotelListingSkeleton"
|
||||||
|
|
||||||
import styles from "./hotelListing.module.css"
|
import styles from "./hotelListing.module.css"
|
||||||
|
|
||||||
export default function HotelListing() {
|
export default function HotelListing() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const hash = useHash()
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
threshold: 300,
|
threshold: 300,
|
||||||
elementRef: scrollRef,
|
elementRef: scrollRef,
|
||||||
})
|
})
|
||||||
const {
|
const { activeHotels, filters, sortItems, isLoading } =
|
||||||
activeHotels,
|
useDestinationDataStore((state) => ({
|
||||||
filters,
|
activeHotels: state.activeHotels,
|
||||||
sortItems,
|
filters: state.allFilters,
|
||||||
initialHashFilterLoaded,
|
sortItems: state.sortItems,
|
||||||
loadInitialHashFilter,
|
isLoading: state.isLoading,
|
||||||
} = useHotelDataStore((state) => ({
|
}))
|
||||||
activeHotels: state.activeHotels,
|
|
||||||
filters: state.allFilters,
|
|
||||||
sortItems: state.sortItems,
|
|
||||||
initialHashFilterLoaded: state.initialHashFilterLoaded,
|
|
||||||
loadInitialHashFilter: state.actions.loadInitialHashFilter,
|
|
||||||
}))
|
|
||||||
const [allHotelsVisible, setAllHotelsVisible] = useState(
|
const [allHotelsVisible, setAllHotelsVisible] = useState(
|
||||||
activeHotels.length <= 5
|
activeHotels.length <= 5
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hash !== undefined && !initialHashFilterLoaded) {
|
|
||||||
loadInitialHashFilter(hash)
|
|
||||||
}
|
|
||||||
}, [hash, loadInitialHashFilter, initialHashFilterLoaded])
|
|
||||||
|
|
||||||
function handleShowMore() {
|
function handleShowMore() {
|
||||||
if (scrollRef.current && allHotelsVisible) {
|
if (scrollRef.current && allHotelsVisible) {
|
||||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
@@ -54,7 +41,9 @@ export default function HotelListing() {
|
|||||||
setAllHotelsVisible((state) => !state)
|
setAllHotelsVisible((state) => !state)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return isLoading ? (
|
||||||
|
<HotelListingSkeleton />
|
||||||
|
) : (
|
||||||
<section className={styles.container} ref={scrollRef}>
|
<section className={styles.container} ref={scrollRef}>
|
||||||
<div className={styles.listHeader}>
|
<div className={styles.listHeader}>
|
||||||
<Subtitle type="two">
|
<Subtitle type="two">
|
||||||
@@ -65,7 +54,11 @@ export default function HotelListing() {
|
|||||||
{ count: activeHotels.length }
|
{ count: activeHotels.length }
|
||||||
)}
|
)}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
|
<DestinationFilterAndSort
|
||||||
|
filters={filters}
|
||||||
|
sortItems={sortItems}
|
||||||
|
listType="hotel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
|
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 500px;
|
||||||
background-color: var(--Base-Surface-Primary-Normal);
|
background-color: var(--Base-Surface-Primary-Normal);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--Spacing-x4);
|
padding: var(--Spacing-x4);
|
||||||
|
|||||||
@@ -3,22 +3,22 @@
|
|||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||||
|
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||||
|
|
||||||
import { getCityHeadingText } from "../utils"
|
import { getHeadingText } from "../utils"
|
||||||
|
|
||||||
import styles from "./sidebarContentWrapper.module.css"
|
import styles from "./sidebarContentWrapper.module.css"
|
||||||
|
|
||||||
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
||||||
cityName?: string
|
location: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarContentWrapper({
|
export default function SidebarContentWrapper({
|
||||||
cityName,
|
location,
|
||||||
children,
|
children,
|
||||||
}: SidebarContentWrapperProps) {
|
}: SidebarContentWrapperProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
@@ -27,17 +27,16 @@ export default function SidebarContentWrapper({
|
|||||||
ref: sidebarRef,
|
ref: sidebarRef,
|
||||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
||||||
})
|
})
|
||||||
const { activeFilters, allFilters } = useHotelDataStore((state) => ({
|
const { activeFilters, allFilters } = useDestinationDataStore((state) => ({
|
||||||
activeFilters: state.activeFilters,
|
activeFilters: state.activeFilters,
|
||||||
allFilters: state.allFilters,
|
allFilters: state.allFilters,
|
||||||
}))
|
}))
|
||||||
const headingText = cityName
|
|
||||||
? getCityHeadingText(intl, cityName, allFilters, activeFilters[0])
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||||
{headingText && <Title level="h2">{headingText}</Title>}
|
<Title level="h2">
|
||||||
|
{getHeadingText(intl, location, allFilters, activeFilters[0])}
|
||||||
|
</Title>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { IntlShape } from "react-intl"
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
|
import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
|
||||||
|
|
||||||
export function getCityHeadingText(
|
export function getHeadingText(
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
cityName: string,
|
location: string,
|
||||||
allFilters: CategorizedFilters,
|
allFilters: CategorizedFilters,
|
||||||
filter?: string
|
filter?: string
|
||||||
) {
|
) {
|
||||||
@@ -18,15 +18,15 @@ export function getCityHeadingText(
|
|||||||
|
|
||||||
if (facilityFilter) {
|
if (facilityFilter) {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{ id: "Hotels with {filter} in {cityName}" },
|
{ id: "Hotels with {filter} in {location}" },
|
||||||
{ cityName, filter: facilityFilter.name }
|
{ location, filter: facilityFilter.name }
|
||||||
)
|
)
|
||||||
} else if (surroudingsFilter) {
|
} else if (surroudingsFilter) {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{ id: "Hotels near {filter} in {cityName}" },
|
{ id: "Hotels near {filter} in {location}" },
|
||||||
{ cityName, filter: surroudingsFilter.name }
|
{ location, filter: surroudingsFilter.name }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return intl.formatMessage({ id: "Hotels in {city}" }, { city: cityName })
|
return intl.formatMessage({ id: "Hotels in {location}" }, { location })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
@@ -11,7 +11,7 @@ import Checkbox from "./Checkbox"
|
|||||||
|
|
||||||
import styles from "./filter.module.css"
|
import styles from "./filter.module.css"
|
||||||
|
|
||||||
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
|
import type { CategorizedFilters } from "@/types/components/destinationFilterAndSort"
|
||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
filters: CategorizedFilters
|
filters: CategorizedFilters
|
||||||
@@ -20,7 +20,7 @@ interface FilterProps {
|
|||||||
export default function Filter({ filters }: FilterProps) {
|
export default function Filter({ filters }: FilterProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { facilityFilters, surroundingsFilters } = filters
|
const { facilityFilters, surroundingsFilters } = filters
|
||||||
const { pendingFilters, togglePendingFilter } = useHotelDataStore(
|
const { pendingFilters, togglePendingFilter } = useDestinationDataStore(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
pendingFilters: state.pendingFilters,
|
pendingFilters: state.pendingFilters,
|
||||||
togglePendingFilter: state.actions.togglePendingFilter,
|
togglePendingFilter: state.actions.togglePendingFilter,
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import Select from "@/components/TempDesignSystem/Select"
|
import Select from "@/components/TempDesignSystem/Select"
|
||||||
|
|
||||||
import type { SortItem } from "@/types/components/hotelFilterAndSort"
|
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||||
import type { SortOption } from "@/types/enums/hotelFilterAndSort"
|
import type { SortOption } from "@/types/enums/destinationFilterAndSort"
|
||||||
|
|
||||||
interface SortProps {
|
interface SortProps {
|
||||||
sortItems: SortItem[]
|
sortItems: SortItem[]
|
||||||
@@ -15,7 +15,7 @@ interface SortProps {
|
|||||||
|
|
||||||
export default function Sort({ sortItems }: SortProps) {
|
export default function Sort({ sortItems }: SortProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { pendingSort, setPendingSort } = useHotelDataStore((state) => ({
|
const { pendingSort, setPendingSort } = useDestinationDataStore((state) => ({
|
||||||
pendingSort: state.pendingSort,
|
pendingSort: state.pendingSort,
|
||||||
setPendingSort: state.actions.setPendingSort,
|
setPendingSort: state.actions.setPendingSort,
|
||||||
}))
|
}))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
} from "react-aria-components"
|
} from "react-aria-components"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -19,39 +20,75 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|||||||
import Filter from "./Filter"
|
import Filter from "./Filter"
|
||||||
import Sort from "./Sort"
|
import Sort from "./Sort"
|
||||||
|
|
||||||
import styles from "./hotelFilterAndSort.module.css"
|
import styles from "./destinationFilterAndSort.module.css"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CategorizedFilters,
|
CategorizedFilters,
|
||||||
SortItem,
|
SortItem,
|
||||||
} from "@/types/components/hotelFilterAndSort"
|
} from "@/types/components/destinationFilterAndSort"
|
||||||
|
|
||||||
interface HotelFilterAndSortProps {
|
interface HotelFilterAndSortProps {
|
||||||
filters: CategorizedFilters
|
filters: CategorizedFilters
|
||||||
sortItems: SortItem[]
|
sortItems: SortItem[]
|
||||||
|
listType: "city" | "hotel"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HotelFilterAndSort({
|
export default function DestinationFilterAndSort({
|
||||||
filters,
|
filters,
|
||||||
sortItems,
|
sortItems,
|
||||||
|
listType,
|
||||||
}: HotelFilterAndSortProps) {
|
}: HotelFilterAndSortProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const router = useRouter()
|
||||||
const {
|
const {
|
||||||
|
pendingFilters,
|
||||||
|
pendingSort,
|
||||||
|
defaultSort,
|
||||||
|
basePath,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
clearPendingFilters,
|
clearPendingFilters,
|
||||||
resetPendingValues,
|
resetPendingValues,
|
||||||
submitFiltersAndSort,
|
setIsLoading,
|
||||||
} = useHotelDataStore((state) => ({
|
} = useDestinationDataStore((state) => ({
|
||||||
pendingCount: state.pendingCount,
|
pendingFilters: state.pendingFilters,
|
||||||
|
pendingSort: state.pendingSort,
|
||||||
|
basePath: state.basePathnameWithoutFilters,
|
||||||
|
defaultSort: state.defaultSort,
|
||||||
|
pendingCount:
|
||||||
|
listType === "city" ? state.pendingCityCount : state.pendingHotelCount,
|
||||||
activeFilters: state.activeFilters,
|
activeFilters: state.activeFilters,
|
||||||
clearPendingFilters: state.actions.clearPendingFilters,
|
clearPendingFilters: state.actions.clearPendingFilters,
|
||||||
resetPendingValues: state.actions.resetPendingValues,
|
resetPendingValues: state.actions.resetPendingValues,
|
||||||
submitFiltersAndSort: state.actions.submitFiltersAndSort,
|
setIsLoading: state.actions.setIsLoading,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function submitAndClose(close: () => void) {
|
function submitAndClose(close: () => void) {
|
||||||
submitFiltersAndSort()
|
setIsLoading(true)
|
||||||
|
const sort = pendingSort
|
||||||
|
const filters = pendingFilters
|
||||||
|
|
||||||
|
const parsedUrl = new URL(window.location.href)
|
||||||
|
const searchParams = parsedUrl.searchParams
|
||||||
|
if (sort === defaultSort && searchParams.has("sort")) {
|
||||||
|
searchParams.delete("sort")
|
||||||
|
} else if (sort !== defaultSort) {
|
||||||
|
searchParams.set("sort", sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstFilter, ...remainingFilters] = filters
|
||||||
|
|
||||||
|
parsedUrl.pathname = basePath
|
||||||
|
if (firstFilter) {
|
||||||
|
parsedUrl.pathname += `/${firstFilter}`
|
||||||
|
}
|
||||||
|
if (remainingFilters.length > 0) {
|
||||||
|
parsedUrl.hash = `#${remainingFilters.join("&")}`
|
||||||
|
} else {
|
||||||
|
parsedUrl.hash = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(parsedUrl.toString(), { scroll: false })
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
6
apps/scandic-web/contexts/DestinationData.ts
Normal file
6
apps/scandic-web/contexts/DestinationData.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
import type { DestinationDataStore } from "@/types/contexts/destination-data"
|
||||||
|
|
||||||
|
export const DestinationDataContext =
|
||||||
|
createContext<DestinationDataStore | null>(null)
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createContext } from "react"
|
|
||||||
|
|
||||||
import type { HotelDataStore } from "@/types/contexts/hotel-data"
|
|
||||||
|
|
||||||
export const HotelDataContext = createContext<HotelDataStore | null>(null)
|
|
||||||
@@ -287,8 +287,9 @@
|
|||||||
"Hotel surroundings": "Hotel omgivelser",
|
"Hotel surroundings": "Hotel omgivelser",
|
||||||
"Hotels": "Hoteller",
|
"Hotels": "Hoteller",
|
||||||
"Hotels & Destinations": "Hoteller & destinationer",
|
"Hotels & Destinations": "Hoteller & destinationer",
|
||||||
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}",
|
"Hotels in {location}": "Hoteller i {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
|
"Hotels near {filter} in {location}": "Hoteller nær {filter} i {location}",
|
||||||
|
"Hotels with {filter} in {location}": "Hoteller med {filter} i {location}",
|
||||||
"Hours": "Tider",
|
"Hours": "Tider",
|
||||||
"How do you want to sleep?": "Hvordan vil du sove?",
|
"How do you want to sleep?": "Hvordan vil du sove?",
|
||||||
"How it works": "Hvordan det virker",
|
"How it works": "Hvordan det virker",
|
||||||
@@ -515,6 +516,7 @@
|
|||||||
"Read more about the hotel": "Læs mere om hotellet",
|
"Read more about the hotel": "Læs mere om hotellet",
|
||||||
"Read more about wellness & exercise": "Læs mere om wellness & motion",
|
"Read more about wellness & exercise": "Læs mere om wellness & motion",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Anbefalet",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -288,8 +288,9 @@
|
|||||||
"Hotel surroundings": "Umgebung des Hotels",
|
"Hotel surroundings": "Umgebung des Hotels",
|
||||||
"Hotels": "Hotels",
|
"Hotels": "Hotels",
|
||||||
"Hotels & Destinations": "Hotels & Reiseziele",
|
"Hotels & Destinations": "Hotels & Reiseziele",
|
||||||
"Hotels near {filter} in {cityName}": "Hotels in der Nähe von {filter} in {cityName}",
|
"Hotels in {location}": "Hotels in {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hotels mit {filter} in {cityName}",
|
"Hotels near {filter} in {location}": "Hotels in der Nähe von {filter} in {location}",
|
||||||
|
"Hotels with {filter} in {location}": "Hotels mit {filter} in {location}",
|
||||||
"Hours": "Zeiten",
|
"Hours": "Zeiten",
|
||||||
"How do you want to sleep?": "Wie möchtest du schlafen?",
|
"How do you want to sleep?": "Wie möchtest du schlafen?",
|
||||||
"How it works": "Wie es funktioniert",
|
"How it works": "Wie es funktioniert",
|
||||||
@@ -517,6 +518,7 @@
|
|||||||
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
|
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
|
||||||
"Read more about wellness & exercise": "Lesen Sie mehr über Wellness & Bewegung",
|
"Read more about wellness & exercise": "Lesen Sie mehr über Wellness & Bewegung",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Empfohlen",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -289,9 +289,9 @@
|
|||||||
"Hotel surroundings": "Hotel surroundings",
|
"Hotel surroundings": "Hotel surroundings",
|
||||||
"Hotels": "Hotels",
|
"Hotels": "Hotels",
|
||||||
"Hotels & Destinations": "Hotels & Destinations",
|
"Hotels & Destinations": "Hotels & Destinations",
|
||||||
"Hotels in {city}": "Hotels in {city}",
|
"Hotels in {location}": "Hotels in {location}",
|
||||||
"Hotels near {filter} in {cityName}": "Hotels near {filter} in {cityName}",
|
"Hotels near {filter} in {location}": "Hotels near {filter} in {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hotels with {filter} in {cityName}",
|
"Hotels with {filter} in {location}": "Hotels with {filter} in {location}",
|
||||||
"Hours": "Hours",
|
"Hours": "Hours",
|
||||||
"How do you want to sleep?": "How do you want to sleep?",
|
"How do you want to sleep?": "How do you want to sleep?",
|
||||||
"How it works": "How it works",
|
"How it works": "How it works",
|
||||||
@@ -519,6 +519,7 @@
|
|||||||
"Read more about the hotel": "Read more about the hotel",
|
"Read more about the hotel": "Read more about the hotel",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Recommended",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -287,8 +287,9 @@
|
|||||||
"Hotel surroundings": "Hotellin ympäristö",
|
"Hotel surroundings": "Hotellin ympäristö",
|
||||||
"Hotels": "Hotellit",
|
"Hotels": "Hotellit",
|
||||||
"Hotels & Destinations": "Hotellit ja Kohteet",
|
"Hotels & Destinations": "Hotellit ja Kohteet",
|
||||||
"Hotels near {filter} in {cityName}": "Hotellit lähellä {filter} kaupungissa {cityName}",
|
"Hotels in {location}": "Hotellit kohteessa {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hotellit, joissa on {filter} kaupungissa {cityName}",
|
"Hotels near {filter} in {location}": "Hotellit lähellä {filter} kaupungissa {location}",
|
||||||
|
"Hotels with {filter} in {location}": "Hotellit, joissa on {filter} kaupungissa {location}",
|
||||||
"Hours": "Ajat",
|
"Hours": "Ajat",
|
||||||
"How do you want to sleep?": "Kuinka haluat nukkua?",
|
"How do you want to sleep?": "Kuinka haluat nukkua?",
|
||||||
"How it works": "Kuinka se toimii",
|
"How it works": "Kuinka se toimii",
|
||||||
@@ -516,6 +517,7 @@
|
|||||||
"Read more about the hotel": "Lue lisää hotellista",
|
"Read more about the hotel": "Lue lisää hotellista",
|
||||||
"Read more about wellness & exercise": "Lue lisää hyvinvoinnista ja liikunnasta",
|
"Read more about wellness & exercise": "Lue lisää hyvinvoinnista ja liikunnasta",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Suositeltu",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -286,8 +286,9 @@
|
|||||||
"Hotel surroundings": "Hotellomgivelser",
|
"Hotel surroundings": "Hotellomgivelser",
|
||||||
"Hotels": "Hoteller",
|
"Hotels": "Hoteller",
|
||||||
"Hotels & Destinations": "Hoteller og Destinasjoner",
|
"Hotels & Destinations": "Hoteller og Destinasjoner",
|
||||||
"Hotels near {filter} in {cityName}": "Hoteller nær {filter} i {cityName}",
|
"Hotels in {location}": "Hoteller i {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hoteller med {filter} i {cityName}",
|
"Hotels near {filter} in {location}": "Hoteller nær {filter} i {location}",
|
||||||
|
"Hotels with {filter} in {location}": "Hoteller med {filter} i {location}",
|
||||||
"Hours": "Tider",
|
"Hours": "Tider",
|
||||||
"How do you want to sleep?": "Hvordan vil du sove?",
|
"How do you want to sleep?": "Hvordan vil du sove?",
|
||||||
"How it works": "Hvordan det fungerer",
|
"How it works": "Hvordan det fungerer",
|
||||||
@@ -513,6 +514,7 @@
|
|||||||
"Read more about the hotel": "Les mer om hotellet",
|
"Read more about the hotel": "Les mer om hotellet",
|
||||||
"Read more about wellness & exercise": "Les mer om velvære og trening",
|
"Read more about wellness & exercise": "Les mer om velvære og trening",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Anbefalt",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -286,8 +286,9 @@
|
|||||||
"Hotel surroundings": "Hotellomgivning",
|
"Hotel surroundings": "Hotellomgivning",
|
||||||
"Hotels": "Hotell",
|
"Hotels": "Hotell",
|
||||||
"Hotels & Destinations": "Hotell & destinationer",
|
"Hotels & Destinations": "Hotell & destinationer",
|
||||||
"Hotels near {filter} in {cityName}": "Hotell nära {filter} i {cityName}",
|
"Hotels in {location}": "Hotell i {location}",
|
||||||
"Hotels with {filter} in {cityName}": "Hotell med {filter} i {cityName}",
|
"Hotels near {filter} in {location}": "Hotell nära {filter} i {location}",
|
||||||
|
"Hotels with {filter} in {location}": "Hotell med {filter} i {location}",
|
||||||
"Hours": "Tider",
|
"Hours": "Tider",
|
||||||
"How do you want to sleep?": "Hur vill du sova?",
|
"How do you want to sleep?": "Hur vill du sova?",
|
||||||
"How it works": "Hur det fungerar",
|
"How it works": "Hur det fungerar",
|
||||||
@@ -513,6 +514,7 @@
|
|||||||
"Read more about the hotel": "Läs mer om hotellet",
|
"Read more about the hotel": "Läs mer om hotellet",
|
||||||
"Read more about wellness & exercise": "Läs mer om friskvård & träning",
|
"Read more about wellness & exercise": "Läs mer om friskvård & träning",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Recommended": "Rekommenderad",
|
||||||
"Redeem benefit": "Redeem benefit",
|
"Redeem benefit": "Redeem benefit",
|
||||||
"Redeemed & valid through:": "Redeemed & valid through:",
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Redirecting you to SAS": "Redirecting you to SAS",
|
"Redirecting you to SAS": "Redirecting you to SAS",
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ query GetDestinationCityListData($locale: String!, $cityIdentifier: String!) {
|
|||||||
) {
|
) {
|
||||||
items {
|
items {
|
||||||
heading
|
heading
|
||||||
|
destination_settings {
|
||||||
|
city_denmark
|
||||||
|
city_finland
|
||||||
|
city_germany
|
||||||
|
city_norway
|
||||||
|
city_poland
|
||||||
|
city_sweden
|
||||||
|
}
|
||||||
|
sort_order
|
||||||
preamble
|
preamble
|
||||||
images {
|
images {
|
||||||
image
|
image
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ query GetDestinationCountryPageMetadata($locale: String!, $uid: String!) {
|
|||||||
...Metadata
|
...Metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
destination_settings {
|
||||||
|
country
|
||||||
|
}
|
||||||
system {
|
system {
|
||||||
...System
|
...System
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
searchParams.set("subpage", subpage)
|
searchParams.set("subpage", subpage)
|
||||||
break
|
break
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
|
case PageContentTypeEnum.destinationCountryPage:
|
||||||
// E.g. Active filters inside destination pages to filter hotels.
|
// E.g. Active filters inside destination pages to filter hotels.
|
||||||
searchParams.set("filterFromUrl", subpage)
|
searchParams.set("filterFromUrl", subpage)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
export default function DestinationDataProviderContent({
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren) {
|
||||||
|
const params = useParams()
|
||||||
|
const { basePath, updateActiveFiltersAndSort } = useDestinationDataStore(
|
||||||
|
(state) => ({
|
||||||
|
basePath: state.basePathnameWithoutFilters,
|
||||||
|
updateActiveFiltersAndSort: state.actions.updateActiveFiltersAndSort,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentUrl = new URL(window.location.href)
|
||||||
|
const searchParams = currentUrl.searchParams
|
||||||
|
const currentPathname = currentUrl.pathname
|
||||||
|
const currentHash = currentUrl.hash
|
||||||
|
const sort = searchParams.get("sort")
|
||||||
|
const filters = []
|
||||||
|
const pathParts = currentPathname.split("/")
|
||||||
|
const lastPathPart = pathParts[pathParts.length - 1]
|
||||||
|
|
||||||
|
if (basePath !== currentPathname) {
|
||||||
|
filters.push(lastPathPart)
|
||||||
|
}
|
||||||
|
if (currentHash) {
|
||||||
|
const hashValue = currentHash.substring(1)
|
||||||
|
filters.push(...hashValue.split("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveFiltersAndSort(filters, sort)
|
||||||
|
}, [params, updateActiveFiltersAndSort, basePath])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
39
apps/scandic-web/providers/DestinationDataProvider/index.tsx
Normal file
39
apps/scandic-web/providers/DestinationDataProvider/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
|
import { createDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
import { DestinationDataContext } from "@/contexts/DestinationData"
|
||||||
|
|
||||||
|
import DestinationDataProviderContent from "./Content"
|
||||||
|
|
||||||
|
import type { DestinationDataStore } from "@/types/contexts/destination-data"
|
||||||
|
import type { DestinationDataProviderProps } from "@/types/providers/destination-data"
|
||||||
|
|
||||||
|
export default function DestinationDataProvider({
|
||||||
|
allCities = [],
|
||||||
|
allHotels,
|
||||||
|
sortItems,
|
||||||
|
children,
|
||||||
|
}: DestinationDataProviderProps) {
|
||||||
|
const storeRef = useRef<DestinationDataStore>()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
if (!storeRef.current) {
|
||||||
|
storeRef.current = createDestinationDataStore({
|
||||||
|
allCities,
|
||||||
|
allHotels,
|
||||||
|
pathname,
|
||||||
|
sortItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DestinationDataContext.Provider value={storeRef.current}>
|
||||||
|
<DestinationDataProviderContent>
|
||||||
|
{children}
|
||||||
|
</DestinationDataProviderContent>
|
||||||
|
</DestinationDataContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useRef } from "react"
|
|
||||||
|
|
||||||
import { createHotelDataStore } from "@/stores/hotel-data"
|
|
||||||
import { DEFAULT_SORT } from "@/stores/hotel-data/helper"
|
|
||||||
|
|
||||||
import { HotelDataContext } from "@/contexts/HotelData"
|
|
||||||
|
|
||||||
import type { HotelDataStore } from "@/types/contexts/hotel-data"
|
|
||||||
import type { HotelDataProviderProps } from "@/types/providers/hotel-data"
|
|
||||||
import type { SubmitCallbackData } from "@/types/stores/hotel-data"
|
|
||||||
|
|
||||||
export default function HotelDataProvider({
|
|
||||||
allHotels,
|
|
||||||
filterFromUrl,
|
|
||||||
sortItems,
|
|
||||||
children,
|
|
||||||
}: HotelDataProviderProps) {
|
|
||||||
const storeRef = useRef<HotelDataStore>()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function submitCallbackFn({ sort, filters, basePath }: SubmitCallbackData) {
|
|
||||||
const parsedUrl = new URL(window.location.href)
|
|
||||||
const searchParams = parsedUrl.searchParams
|
|
||||||
if (sort === DEFAULT_SORT && searchParams.has("sort")) {
|
|
||||||
searchParams.delete("sort")
|
|
||||||
} else if (sort !== DEFAULT_SORT) {
|
|
||||||
searchParams.set("sort", sort)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [firstFilter, ...remainingFilters] = filters
|
|
||||||
|
|
||||||
parsedUrl.pathname = basePath
|
|
||||||
if (firstFilter) {
|
|
||||||
parsedUrl.pathname += `/${firstFilter}`
|
|
||||||
}
|
|
||||||
if (remainingFilters.length > 0) {
|
|
||||||
parsedUrl.hash = `#${remainingFilters.join("&")}`
|
|
||||||
} else {
|
|
||||||
parsedUrl.hash = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(parsedUrl.toString(), { scroll: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!storeRef.current) {
|
|
||||||
storeRef.current = createHotelDataStore({
|
|
||||||
allHotels,
|
|
||||||
pathname,
|
|
||||||
searchParams,
|
|
||||||
filterFromUrl,
|
|
||||||
sortItems,
|
|
||||||
submitCallbackFn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HotelDataContext.Provider value={storeRef.current}>
|
|
||||||
{children}
|
|
||||||
</HotelDataContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,37 @@ export const destinationCityListDataSchema = z
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
heading: z.string(),
|
heading: z.string(),
|
||||||
|
destination_settings: z
|
||||||
|
.object({
|
||||||
|
city_denmark: z.string().optional().nullable(),
|
||||||
|
city_finland: z.string().optional().nullable(),
|
||||||
|
city_germany: z.string().optional().nullable(),
|
||||||
|
city_poland: z.string().optional().nullable(),
|
||||||
|
city_norway: z.string().optional().nullable(),
|
||||||
|
city_sweden: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(
|
||||||
|
({
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_norway,
|
||||||
|
city_poland,
|
||||||
|
city_sweden,
|
||||||
|
}) => {
|
||||||
|
const cities = [
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_poland,
|
||||||
|
city_norway,
|
||||||
|
city_sweden,
|
||||||
|
].filter((city): city is string => Boolean(city))
|
||||||
|
|
||||||
|
return { city: cities[0] }
|
||||||
|
}
|
||||||
|
),
|
||||||
|
sort_order: z.number().nullable(),
|
||||||
preamble: z.string(),
|
preamble: z.string(),
|
||||||
experiences: z
|
experiences: z
|
||||||
.object({
|
.object({
|
||||||
@@ -81,6 +112,37 @@ export const blocksSchema = z.discriminatedUnion("__typename", [
|
|||||||
destinationCityPageContent,
|
destinationCityPageContent,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const destinationCityPageDestinationSettingsSchema = z
|
||||||
|
.object({
|
||||||
|
city_denmark: z.string().optional().nullable(),
|
||||||
|
city_finland: z.string().optional().nullable(),
|
||||||
|
city_germany: z.string().optional().nullable(),
|
||||||
|
city_poland: z.string().optional().nullable(),
|
||||||
|
city_norway: z.string().optional().nullable(),
|
||||||
|
city_sweden: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform(
|
||||||
|
({
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_norway,
|
||||||
|
city_poland,
|
||||||
|
city_sweden,
|
||||||
|
}) => {
|
||||||
|
const cities = [
|
||||||
|
city_denmark,
|
||||||
|
city_finland,
|
||||||
|
city_germany,
|
||||||
|
city_poland,
|
||||||
|
city_norway,
|
||||||
|
city_sweden,
|
||||||
|
].filter((city): city is string => Boolean(city))
|
||||||
|
|
||||||
|
return { city: cities[0] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const destinationCityPageSchema = z
|
export const destinationCityPageSchema = z
|
||||||
.object({
|
.object({
|
||||||
destination_city_page: z.object({
|
destination_city_page: z.object({
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ import { z } from "zod"
|
|||||||
|
|
||||||
export const getMetadataInput = z.object({
|
export const getMetadataInput = z.object({
|
||||||
subpage: z.string().optional(),
|
subpage: z.string().optional(),
|
||||||
|
filterFromUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getDescription, getImage, getTitle } from "./utils"
|
|||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
|
import { Country } from "@/types/enums/country"
|
||||||
import { RTETypeEnum } from "@/types/rte/enums"
|
import { RTETypeEnum } from "@/types/rte/enums"
|
||||||
|
|
||||||
const metaDataJsonSchema = z.object({
|
const metaDataJsonSchema = z.object({
|
||||||
@@ -70,6 +71,7 @@ export const rawMetadataSchema = z.object({
|
|||||||
city_poland: z.string().optional().nullable(),
|
city_poland: z.string().optional().nullable(),
|
||||||
city_norway: z.string().optional().nullable(),
|
city_norway: z.string().optional().nullable(),
|
||||||
city_sweden: z.string().optional().nullable(),
|
city_sweden: z.string().optional().nullable(),
|
||||||
|
country: z.nativeEnum(Country).optional().nullable(),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
@@ -89,9 +91,9 @@ export const rawMetadataSchema = z.object({
|
|||||||
.pick({ name: true, address: true, hotelContent: true, gallery: true })
|
.pick({ name: true, address: true, hotelContent: true, gallery: true })
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
cityName: z.string().optional().nullable(),
|
location: z.string().optional().nullable(),
|
||||||
cityFilter: z.string().optional().nullable(),
|
filter: z.string().optional().nullable(),
|
||||||
cityFilterType: z.enum(["facility", "surroundings"]).optional().nullable(),
|
filterType: z.enum(["facility", "surroundings"]).optional().nullable(),
|
||||||
system: systemSchema,
|
system: systemSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { generateTag } from "@/utils/generateTag"
|
|||||||
import { getHotel } from "../../hotels/query"
|
import { getHotel } from "../../hotels/query"
|
||||||
import { getMetadataInput } from "./input"
|
import { getMetadataInput } from "./input"
|
||||||
import { metadataSchema } from "./output"
|
import { metadataSchema } from "./output"
|
||||||
import { affix, getCityData } from "./utils"
|
import { affix, getCityData, getCountryData } from "./utils"
|
||||||
|
|
||||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||||
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
|
||||||
@@ -153,9 +153,16 @@ export const metadataQueryRouter = router({
|
|||||||
const destinationCountryPageResponse = await fetchMetadata<{
|
const destinationCountryPageResponse = await fetchMetadata<{
|
||||||
destination_country_page: RawMetadataSchema
|
destination_country_page: RawMetadataSchema
|
||||||
}>(GetDestinationCountryPageMetadata, variables)
|
}>(GetDestinationCountryPageMetadata, variables)
|
||||||
return getTransformedMetadata(
|
const countryData = await getCountryData(
|
||||||
destinationCountryPageResponse.destination_country_page
|
destinationCountryPageResponse.destination_country_page,
|
||||||
|
input,
|
||||||
|
ctx.serviceToken,
|
||||||
|
ctx.lang
|
||||||
)
|
)
|
||||||
|
return getTransformedMetadata({
|
||||||
|
...destinationCountryPageResponse.destination_country_page,
|
||||||
|
...countryData,
|
||||||
|
})
|
||||||
case PageContentTypeEnum.destinationCityPage:
|
case PageContentTypeEnum.destinationCityPage:
|
||||||
const destinationCityPageResponse = await fetchMetadata<{
|
const destinationCityPageResponse = await fetchMetadata<{
|
||||||
destination_city_page: RawMetadataSchema
|
destination_city_page: RawMetadataSchema
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { getFiltersFromHotels } from "@/stores/hotel-data/helper"
|
import { ApiLang, type Lang } from "@/constants/languages"
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
import { getFiltersFromHotels } from "@/stores/destination-data/helper"
|
||||||
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCityByCityIdentifier,
|
getCityByCityIdentifier,
|
||||||
getHotelIdsByCityIdentifier,
|
getHotelIdsByCityIdentifier,
|
||||||
|
getHotelIdsByCountry,
|
||||||
getHotelsByHotelIds,
|
getHotelsByHotelIds,
|
||||||
} from "../../hotels/utils"
|
} from "../../hotels/utils"
|
||||||
|
|
||||||
|
import { ApiCountry } from "@/types/enums/country"
|
||||||
|
import type { RequestOptionsWithOutBody } from "@/types/fetch"
|
||||||
import { RTETypeEnum } from "@/types/rte/enums"
|
import { RTETypeEnum } from "@/types/rte/enums"
|
||||||
import type {
|
import type {
|
||||||
MetadataInputSchema,
|
MetadataInputSchema,
|
||||||
RawMetadataSchema,
|
RawMetadataSchema,
|
||||||
} from "@/types/trpc/routers/contentstack/metadata"
|
} from "@/types/trpc/routers/contentstack/metadata"
|
||||||
import type { Lang } from "@/constants/languages"
|
|
||||||
|
|
||||||
export const affix = "metadata"
|
export const affix = "metadata"
|
||||||
|
|
||||||
@@ -87,25 +91,26 @@ export async function getTitle(data: RawMetadataSchema) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (data.system.content_type_uid === "destination_city_page") {
|
if (
|
||||||
if (data.cityName) {
|
data.system.content_type_uid === "destination_city_page" ||
|
||||||
if (data.cityFilter) {
|
data.system.content_type_uid === "destination_country_page"
|
||||||
if (data.cityFilterType === "facility") {
|
) {
|
||||||
|
const { location, filter, filterType } = data
|
||||||
|
if (location) {
|
||||||
|
if (filter) {
|
||||||
|
if (filterType === "facility") {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{ id: "Hotels with {filter} in {cityName}" },
|
{ id: "Hotels with {filter} in {location}" },
|
||||||
{ cityName: data.cityName, filter: data.cityFilter }
|
{ location, filter }
|
||||||
)
|
)
|
||||||
} else if (data.cityFilterType === "surroundings") {
|
} else if (filterType === "surroundings") {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{ id: "Hotels near {filter} in {cityName}" },
|
{ id: "Hotels near {filter} in {location}" },
|
||||||
{ cityName: data.cityName, filter: data.cityFilter }
|
{ location, filter }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return intl.formatMessage(
|
return intl.formatMessage({ id: "Hotels in {location}" }, { location })
|
||||||
{ id: "Hotels in {city}" },
|
|
||||||
{ city: data.cityName }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.web?.breadcrumbs?.title) {
|
if (data.web?.breadcrumbs?.title) {
|
||||||
@@ -190,10 +195,7 @@ export async function getCityData(
|
|||||||
lang: Lang
|
lang: Lang
|
||||||
) {
|
) {
|
||||||
const destinationSettings = data.destination_settings
|
const destinationSettings = data.destination_settings
|
||||||
const cityFilter = input.subpage
|
const filter = input.filterFromUrl
|
||||||
let cityIdentifier
|
|
||||||
let cityData
|
|
||||||
let filterType
|
|
||||||
|
|
||||||
if (destinationSettings) {
|
if (destinationSettings) {
|
||||||
const {
|
const {
|
||||||
@@ -213,23 +215,27 @@ export async function getCityData(
|
|||||||
city_sweden,
|
city_sweden,
|
||||||
].filter((city): city is string => Boolean(city))
|
].filter((city): city is string => Boolean(city))
|
||||||
|
|
||||||
cityIdentifier = cities[0]
|
const cityIdentifier = cities[0]
|
||||||
|
|
||||||
if (cityIdentifier) {
|
if (cityIdentifier) {
|
||||||
cityData = await getCityByCityIdentifier(cityIdentifier, serviceToken)
|
const cityData = await getCityByCityIdentifier(
|
||||||
|
cityIdentifier,
|
||||||
|
serviceToken
|
||||||
|
)
|
||||||
const hotelIds = await getHotelIdsByCityIdentifier(
|
const hotelIds = await getHotelIdsByCityIdentifier(
|
||||||
cityIdentifier,
|
cityIdentifier,
|
||||||
serviceToken
|
serviceToken
|
||||||
)
|
)
|
||||||
|
|
||||||
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
|
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
|
||||||
|
let filterType
|
||||||
|
|
||||||
if (cityFilter) {
|
if (filter) {
|
||||||
const allFilters = getFiltersFromHotels(hotels)
|
const allFilters = getFiltersFromHotels(hotels)
|
||||||
const facilityFilter = allFilters.facilityFilters.find(
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
(f) => f.slug === cityFilter
|
(f) => f.slug === filter
|
||||||
)
|
)
|
||||||
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
(f) => f.slug === cityFilter
|
(f) => f.slug === filter
|
||||||
)
|
)
|
||||||
|
|
||||||
if (facilityFilter) {
|
if (facilityFilter) {
|
||||||
@@ -238,8 +244,65 @@ export async function getCityData(
|
|||||||
filterType = "surroundings"
|
filterType = "surroundings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { location: cityData?.name, filter, filterType }
|
||||||
}
|
}
|
||||||
return { cityName: cityData?.name, cityFilter, cityFilterType: filterType }
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountryData(
|
||||||
|
data: RawMetadataSchema,
|
||||||
|
input: MetadataInputSchema,
|
||||||
|
serviceToken: string,
|
||||||
|
lang: Lang
|
||||||
|
) {
|
||||||
|
const country = data.destination_settings?.country
|
||||||
|
const filter = input.filterFromUrl
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
const translatedCountry = ApiCountry[lang][country]
|
||||||
|
let filterType
|
||||||
|
|
||||||
|
const options: RequestOptionsWithOutBody = {
|
||||||
|
// needs to clear default option as only
|
||||||
|
// cache or next.revalidate is permitted
|
||||||
|
cache: undefined,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${serviceToken}`,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: env.CACHE_TIME_HOTELS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const hotelIdsParams = new URLSearchParams({
|
||||||
|
language: ApiLang.En,
|
||||||
|
country,
|
||||||
|
})
|
||||||
|
const hotelIds = await getHotelIdsByCountry(
|
||||||
|
country,
|
||||||
|
options,
|
||||||
|
hotelIdsParams
|
||||||
|
)
|
||||||
|
|
||||||
|
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
const allFilters = getFiltersFromHotels(hotels)
|
||||||
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||||
|
(f) => f.slug === filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if (facilityFilter) {
|
||||||
|
filterType = "facility"
|
||||||
|
} else if (surroudingsFilter) {
|
||||||
|
filterType = "surroundings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { location: translatedCountry, filter, filterType }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type {
|
|||||||
CategorizedFilters,
|
CategorizedFilters,
|
||||||
Filter,
|
Filter,
|
||||||
SortItem,
|
SortItem,
|
||||||
} from "@/types/components/hotelFilterAndSort"
|
} from "@/types/components/destinationFilterAndSort"
|
||||||
import { SortOption } from "@/types/enums/hotelFilterAndSort"
|
import { SortOption } from "@/types/enums/destinationFilterAndSort"
|
||||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||||
|
import type { DestinationCityListItem } from "@/types/trpc/routers/contentstack/destinationCityPage"
|
||||||
|
|
||||||
export const SORTING_STRATAGIES: Record<
|
const HOTEL_SORTING_STRATEGIES: Partial<
|
||||||
SortOption,
|
Record<SortOption, (a: HotelDataWithUrl, b: HotelDataWithUrl) => number>
|
||||||
(a: HotelDataWithUrl, b: HotelDataWithUrl) => number
|
|
||||||
> = {
|
> = {
|
||||||
[SortOption.Name]: function (a, b) {
|
[SortOption.Name]: function (a, b) {
|
||||||
return a.hotel.name.localeCompare(b.hotel.name)
|
return a.hotel.name.localeCompare(b.hotel.name)
|
||||||
@@ -24,6 +24,29 @@ export const SORTING_STRATAGIES: Record<
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CITY_SORTING_STRATEGIES: Partial<
|
||||||
|
Record<
|
||||||
|
SortOption,
|
||||||
|
(a: DestinationCityListItem, b: DestinationCityListItem) => number
|
||||||
|
>
|
||||||
|
> = {
|
||||||
|
[SortOption.Name]: function (a, b) {
|
||||||
|
return a.cityName.localeCompare(b.cityName)
|
||||||
|
},
|
||||||
|
[SortOption.Recommended]: function (a, b) {
|
||||||
|
if (a.sort_order === null && b.sort_order === null) {
|
||||||
|
return a.cityName.localeCompare(b.cityName)
|
||||||
|
}
|
||||||
|
if (a.sort_order === null) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (b.sort_order === null) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return b.sort_order - a.sort_order
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export function getFilteredHotels(
|
export function getFilteredHotels(
|
||||||
hotels: HotelDataWithUrl[],
|
hotels: HotelDataWithUrl[],
|
||||||
filters: string[]
|
filters: string[]
|
||||||
@@ -38,14 +61,35 @@ export function getFilteredHotels(
|
|||||||
return hotels
|
return hotels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFilteredCities(
|
||||||
|
filteredHotels: HotelDataWithUrl[],
|
||||||
|
cities: DestinationCityListItem[]
|
||||||
|
) {
|
||||||
|
const filteredCityIdentifiers = filteredHotels.map(
|
||||||
|
(hotel) => hotel.cities[0].cityIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
return cities.filter((city) =>
|
||||||
|
filteredCityIdentifiers.includes(city.destination_settings.city)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSortedCities(
|
||||||
|
cities: DestinationCityListItem[],
|
||||||
|
sortOption: SortOption
|
||||||
|
) {
|
||||||
|
const sortFn = CITY_SORTING_STRATEGIES[sortOption]
|
||||||
|
return sortFn ? cities.sort(sortFn) : cities
|
||||||
|
}
|
||||||
|
|
||||||
export function getSortedHotels(
|
export function getSortedHotels(
|
||||||
hotels: HotelDataWithUrl[],
|
hotels: HotelDataWithUrl[],
|
||||||
sortOption: SortOption
|
sortOption: SortOption
|
||||||
) {
|
) {
|
||||||
return hotels.sort(SORTING_STRATAGIES[sortOption])
|
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
|
||||||
|
return sortFn ? hotels.sort(sortFn) : hotels
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SORT = SortOption.Distance
|
|
||||||
export function isValidSortOption(
|
export function isValidSortOption(
|
||||||
value: string,
|
value: string,
|
||||||
sortItems: SortItem[]
|
sortItems: SortItem[]
|
||||||
150
apps/scandic-web/stores/destination-data/index.ts
Normal file
150
apps/scandic-web/stores/destination-data/index.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { produce } from "immer"
|
||||||
|
import { useContext } from "react"
|
||||||
|
import { create, useStore } from "zustand"
|
||||||
|
|
||||||
|
import { DestinationDataContext } from "@/contexts/DestinationData"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBasePathNameWithoutFilters,
|
||||||
|
getFilteredCities,
|
||||||
|
getFilteredHotels,
|
||||||
|
getFiltersFromHotels,
|
||||||
|
getSortedCities,
|
||||||
|
getSortedHotels,
|
||||||
|
isValidSortOption,
|
||||||
|
} from "./helper"
|
||||||
|
|
||||||
|
import type { Filter } from "@/types/components/destinationFilterAndSort"
|
||||||
|
import type {
|
||||||
|
DestinationDataState,
|
||||||
|
InitialState,
|
||||||
|
} from "@/types/stores/destination-data"
|
||||||
|
|
||||||
|
export function createDestinationDataStore({
|
||||||
|
allCities,
|
||||||
|
allHotels,
|
||||||
|
pathname,
|
||||||
|
sortItems,
|
||||||
|
}: InitialState) {
|
||||||
|
const defaultSort =
|
||||||
|
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
|
||||||
|
|
||||||
|
const allFilters = getFiltersFromHotels(allHotels)
|
||||||
|
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
|
||||||
|
filter.map((f) => f.slug)
|
||||||
|
)
|
||||||
|
|
||||||
|
return create<DestinationDataState>((set) => ({
|
||||||
|
actions: {
|
||||||
|
updateActiveFiltersAndSort(filters, sort) {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
const newSort =
|
||||||
|
sort && isValidSortOption(sort, state.sortItems)
|
||||||
|
? sort
|
||||||
|
: state.defaultSort
|
||||||
|
const filteredHotels = getFilteredHotels(state.allHotels, filters)
|
||||||
|
const sortedHotels = getSortedHotels(filteredHotels, newSort)
|
||||||
|
const filteredCities = state.allHotels.length
|
||||||
|
? getFilteredCities(filteredHotels, state.allCities)
|
||||||
|
: []
|
||||||
|
const sortedCities = getSortedCities(filteredCities, newSort)
|
||||||
|
|
||||||
|
state.activeSort = newSort
|
||||||
|
state.activeFilters = filters
|
||||||
|
state.activeHotels = sortedHotels
|
||||||
|
state.activeCities = sortedCities
|
||||||
|
|
||||||
|
state.pendingFilters = filters
|
||||||
|
state.pendingSort = newSort
|
||||||
|
state.pendingHotelCount = filteredHotels.length
|
||||||
|
state.pendingCityCount = filteredCities.length
|
||||||
|
state.isLoading = false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
setIsLoading(isLoading) {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
state.isLoading = isLoading
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
setPendingSort(sort) {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
state.pendingSort = sort
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
togglePendingFilter(filter) {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
const isActive = state.pendingFilters.includes(filter)
|
||||||
|
const filters = isActive
|
||||||
|
? state.pendingFilters.filter((f) => f !== filter)
|
||||||
|
: [...state.pendingFilters, filter]
|
||||||
|
const pendingHotels = getFilteredHotels(state.allHotels, filters)
|
||||||
|
const pendingCities = state.allHotels.length
|
||||||
|
? getFilteredCities(pendingHotels, state.allCities)
|
||||||
|
: []
|
||||||
|
|
||||||
|
state.pendingFilters = filters
|
||||||
|
state.pendingHotelCount = pendingHotels.length
|
||||||
|
state.pendingCityCount = pendingCities.length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clearPendingFilters() {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
state.pendingFilters = []
|
||||||
|
state.pendingHotelCount = state.allHotels.length
|
||||||
|
state.pendingCityCount = state.allCities.length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
resetPendingValues() {
|
||||||
|
return set(
|
||||||
|
produce((state: DestinationDataState) => {
|
||||||
|
state.pendingFilters = state.activeFilters
|
||||||
|
state.pendingSort = state.activeSort
|
||||||
|
state.pendingHotelCount = state.activeHotels.length
|
||||||
|
state.pendingCityCount = state.activeCities.length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allHotels,
|
||||||
|
activeHotels: allHotels,
|
||||||
|
pendingHotelCount: allHotels.length,
|
||||||
|
allCities,
|
||||||
|
activeCities: allCities,
|
||||||
|
pendingCityCount: allCities.length,
|
||||||
|
activeSort: defaultSort,
|
||||||
|
pendingSort: defaultSort,
|
||||||
|
defaultSort,
|
||||||
|
activeFilters: [],
|
||||||
|
pendingFilters: [],
|
||||||
|
allFilters,
|
||||||
|
allFilterSlugs,
|
||||||
|
basePathnameWithoutFilters: getBasePathNameWithoutFilters(
|
||||||
|
pathname,
|
||||||
|
allFilterSlugs
|
||||||
|
),
|
||||||
|
sortItems,
|
||||||
|
isLoading: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDestinationDataStore<T>(
|
||||||
|
selector: (store: DestinationDataState) => T
|
||||||
|
) {
|
||||||
|
const store = useContext(DestinationDataContext)
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("useHotelDataStore must be used within HotelDataProvider")
|
||||||
|
}
|
||||||
|
|
||||||
|
return useStore(store, selector)
|
||||||
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { produce } from "immer"
|
|
||||||
import { useContext } from "react"
|
|
||||||
import { create, useStore } from "zustand"
|
|
||||||
|
|
||||||
import { HotelDataContext } from "@/contexts/HotelData"
|
|
||||||
|
|
||||||
import {
|
|
||||||
getBasePathNameWithoutFilters,
|
|
||||||
getFilteredHotels,
|
|
||||||
getFiltersFromHotels,
|
|
||||||
getSortedHotels,
|
|
||||||
isValidSortOption,
|
|
||||||
} from "./helper"
|
|
||||||
|
|
||||||
import type { Filter } from "@/types/components/hotelFilterAndSort"
|
|
||||||
import { SortOption } from "@/types/enums/hotelFilterAndSort"
|
|
||||||
import type { HotelDataState, InitialState } from "@/types/stores/hotel-data"
|
|
||||||
|
|
||||||
export function createHotelDataStore({
|
|
||||||
allHotels,
|
|
||||||
searchParams,
|
|
||||||
pathname,
|
|
||||||
filterFromUrl,
|
|
||||||
sortItems,
|
|
||||||
submitCallbackFn,
|
|
||||||
}: InitialState) {
|
|
||||||
const sortFromSearchParams = searchParams.get("sort")
|
|
||||||
const initialFilters = filterFromUrl ? [filterFromUrl] : []
|
|
||||||
let initialSort = SortOption.Distance
|
|
||||||
if (
|
|
||||||
sortFromSearchParams &&
|
|
||||||
isValidSortOption(sortFromSearchParams, sortItems)
|
|
||||||
) {
|
|
||||||
initialSort = sortFromSearchParams
|
|
||||||
}
|
|
||||||
const initialFilteredHotels = getFilteredHotels(allHotels, initialFilters)
|
|
||||||
const initialActiveHotels = getSortedHotels(
|
|
||||||
initialFilteredHotels,
|
|
||||||
initialSort
|
|
||||||
)
|
|
||||||
const allFilters = getFiltersFromHotels(allHotels)
|
|
||||||
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
|
|
||||||
filter.map((f) => f.slug)
|
|
||||||
)
|
|
||||||
|
|
||||||
return create<HotelDataState>((set) => ({
|
|
||||||
actions: {
|
|
||||||
submitFiltersAndSort() {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
const sort = state.pendingSort
|
|
||||||
const filters = state.pendingFilters
|
|
||||||
const filteredHotels = getFilteredHotels(state.allHotels, filters)
|
|
||||||
const sortedHotels = getSortedHotels(filteredHotels, sort)
|
|
||||||
|
|
||||||
state.activeSort = sort
|
|
||||||
state.activeFilters = state.pendingFilters
|
|
||||||
state.activeHotels = sortedHotels
|
|
||||||
state.pendingCount = filteredHotels.length
|
|
||||||
|
|
||||||
if (submitCallbackFn) {
|
|
||||||
submitCallbackFn({
|
|
||||||
sort,
|
|
||||||
filters,
|
|
||||||
basePath: state.basePathnameWithoutFilters,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
setPendingSort(sort) {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
state.pendingSort = sort
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
togglePendingFilter(filter) {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
const isActive = state.pendingFilters.includes(filter)
|
|
||||||
const filters = isActive
|
|
||||||
? state.pendingFilters.filter((f) => f !== filter)
|
|
||||||
: [...state.pendingFilters, filter]
|
|
||||||
const pendingHotels = getFilteredHotels(state.allHotels, filters)
|
|
||||||
|
|
||||||
state.pendingFilters = filters
|
|
||||||
state.pendingCount = pendingHotels.length
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
clearPendingFilters() {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
state.pendingFilters = []
|
|
||||||
state.pendingCount = state.allHotels.length
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
resetPendingValues() {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
state.pendingFilters = state.activeFilters
|
|
||||||
state.pendingSort = state.activeSort
|
|
||||||
state.pendingCount = state.activeHotels.length
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
loadInitialHashFilter(hash) {
|
|
||||||
return set(
|
|
||||||
produce((state: HotelDataState) => {
|
|
||||||
state.initialHashFilterLoaded = true
|
|
||||||
|
|
||||||
const filters = []
|
|
||||||
const filtersFromHash = hash.split("&").filter(Boolean) ?? []
|
|
||||||
if (filterFromUrl) {
|
|
||||||
filters.push(filterFromUrl, ...filtersFromHash)
|
|
||||||
}
|
|
||||||
const filteredHotels = getFilteredHotels(state.allHotels, filters)
|
|
||||||
const sortedHotels = getSortedHotels(
|
|
||||||
filteredHotels,
|
|
||||||
state.activeSort
|
|
||||||
)
|
|
||||||
state.activeHotels = sortedHotels
|
|
||||||
state.activeFilters = filters
|
|
||||||
state.pendingFilters = filters
|
|
||||||
state.pendingCount = filteredHotels.length
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allHotels,
|
|
||||||
activeHotels: initialActiveHotels,
|
|
||||||
pendingCount: initialActiveHotels.length,
|
|
||||||
activeSort: initialSort,
|
|
||||||
pendingSort: initialSort,
|
|
||||||
activeFilters: initialFilters,
|
|
||||||
pendingFilters: initialFilters,
|
|
||||||
searchParams,
|
|
||||||
allFilters,
|
|
||||||
allFilterSlugs,
|
|
||||||
basePathnameWithoutFilters: getBasePathNameWithoutFilters(
|
|
||||||
pathname,
|
|
||||||
allFilterSlugs
|
|
||||||
),
|
|
||||||
sortItems,
|
|
||||||
initialHashFilterLoaded: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHotelDataStore<T>(selector: (store: HotelDataState) => T) {
|
|
||||||
const store = useContext(HotelDataContext)
|
|
||||||
|
|
||||||
if (!store) {
|
|
||||||
throw new Error("useHotelDataStore must be used within HotelDataProvider")
|
|
||||||
}
|
|
||||||
|
|
||||||
return useStore(store, selector)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { SortOption } from "../enums/hotelFilterAndSort"
|
import type { SortOption } from "../enums/destinationFilterAndSort"
|
||||||
|
|
||||||
export interface SortItem {
|
export interface SortItem {
|
||||||
label: string
|
label: string
|
||||||
value: SortOption
|
value: SortOption
|
||||||
|
isDefault?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
3
apps/scandic-web/types/contexts/destination-data.ts
Normal file
3
apps/scandic-web/types/contexts/destination-data.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { createDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
|
export type DestinationDataStore = ReturnType<typeof createDestinationDataStore>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import type { createHotelDataStore } from "@/stores/hotel-data"
|
|
||||||
|
|
||||||
export type HotelDataStore = ReturnType<typeof createHotelDataStore>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export enum SortOption {
|
export enum SortOption {
|
||||||
|
Recommended = "recommended",
|
||||||
Distance = "distance",
|
Distance = "distance",
|
||||||
Name = "name",
|
Name = "name",
|
||||||
TripAdvisorRating = "tripadvisor",
|
TripAdvisorRating = "tripadvisor",
|
||||||
9
apps/scandic-web/types/providers/destination-data.ts
Normal file
9
apps/scandic-web/types/providers/destination-data.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||||
|
import type { SortItem } from "../components/destinationFilterAndSort"
|
||||||
|
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
|
||||||
|
|
||||||
|
export interface DestinationDataProviderProps extends React.PropsWithChildren {
|
||||||
|
allHotels: HotelDataWithUrl[]
|
||||||
|
allCities?: DestinationCityListItem[]
|
||||||
|
sortItems: SortItem[]
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
|
||||||
import type { SortItem } from "../components/hotelFilterAndSort"
|
|
||||||
|
|
||||||
export interface HotelDataProviderProps extends React.PropsWithChildren {
|
|
||||||
allHotels: HotelDataWithUrl[]
|
|
||||||
filterFromUrl?: string
|
|
||||||
sortItems: SortItem[]
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,47 @@
|
|||||||
import type { ReadonlyURLSearchParams } from "next/navigation"
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CategorizedFilters,
|
CategorizedFilters,
|
||||||
SortItem,
|
SortItem,
|
||||||
} from "../components/hotelFilterAndSort"
|
} from "../components/destinationFilterAndSort"
|
||||||
import type { SortOption } from "../enums/hotelFilterAndSort"
|
import type { SortOption } from "../enums/destinationFilterAndSort"
|
||||||
import type { HotelDataWithUrl } from "../hotel"
|
import type { HotelDataWithUrl } from "../hotel"
|
||||||
|
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
|
||||||
|
|
||||||
interface Actions {
|
interface Actions {
|
||||||
submitFiltersAndSort: () => void
|
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
|
||||||
setPendingSort: (sort: SortOption) => void
|
setPendingSort: (sort: SortOption) => void
|
||||||
togglePendingFilter: (filter: string) => void
|
togglePendingFilter: (filter: string) => void
|
||||||
clearPendingFilters: () => void
|
clearPendingFilters: () => void
|
||||||
resetPendingValues: () => void
|
resetPendingValues: () => void
|
||||||
loadInitialHashFilter: (hash: string) => void
|
setIsLoading: (isLoading: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitCallbackData {
|
export interface SubmitCallbackData {
|
||||||
sort: SortOption
|
sort: SortOption
|
||||||
|
defaultSort: SortOption
|
||||||
filters: string[]
|
filters: string[]
|
||||||
basePath: string
|
basePath: string
|
||||||
}
|
}
|
||||||
export interface HotelDataState {
|
export interface DestinationDataState {
|
||||||
actions: Actions
|
actions: Actions
|
||||||
|
allCities: DestinationCityListItem[]
|
||||||
|
activeCities: DestinationCityListItem[]
|
||||||
allHotels: HotelDataWithUrl[]
|
allHotels: HotelDataWithUrl[]
|
||||||
activeHotels: HotelDataWithUrl[]
|
activeHotels: HotelDataWithUrl[]
|
||||||
pendingSort: SortOption
|
pendingSort: SortOption
|
||||||
activeSort: SortOption
|
activeSort: SortOption
|
||||||
|
defaultSort: SortOption
|
||||||
pendingFilters: string[]
|
pendingFilters: string[]
|
||||||
activeFilters: string[]
|
activeFilters: string[]
|
||||||
pendingCount: number
|
pendingHotelCount: number
|
||||||
searchParams: ReadonlyURLSearchParams
|
pendingCityCount: number
|
||||||
allFilters: CategorizedFilters
|
allFilters: CategorizedFilters
|
||||||
allFilterSlugs: string[]
|
allFilterSlugs: string[]
|
||||||
basePathnameWithoutFilters: string
|
basePathnameWithoutFilters: string
|
||||||
sortItems: SortItem[]
|
sortItems: SortItem[]
|
||||||
initialHashFilterLoaded: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitialState
|
export interface InitialState
|
||||||
extends Pick<HotelDataState, "allHotels" | "searchParams" | "sortItems"> {
|
extends Pick<DestinationDataState, "allHotels" | "allCities" | "sortItems"> {
|
||||||
pathname: string
|
pathname: string
|
||||||
filterFromUrl?: string
|
|
||||||
submitCallbackFn?: (data: SubmitCallbackData) => void
|
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,14 @@ import type {
|
|||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, { subpage?: string }>) {
|
}: PageArgs<
|
||||||
const { subpage } = searchParams
|
LangParams & ContentTypeParams & UIDParams,
|
||||||
|
{ subpage?: string; filterFromUrl?: string }
|
||||||
|
>) {
|
||||||
|
const { subpage, filterFromUrl } = searchParams
|
||||||
const metadata = await serverClient().contentstack.metadata.get({
|
const metadata = await serverClient().contentstack.metadata.get({
|
||||||
subpage,
|
subpage,
|
||||||
|
filterFromUrl,
|
||||||
})
|
})
|
||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user