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

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

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

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


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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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