diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx index 342dc6031..18cf5ddb9 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx @@ -32,7 +32,10 @@ export { generateMetadata } from "@/utils/generateMetadata" export default async function ContentTypePage({ params, searchParams, -}: PageArgs) { +}: PageArgs< + LangParams & ContentTypeParams & UIDParams, + { subpage?: string; filterFromUrl?: string } +>) { const pathname = headers().get("x-pathname") || "" switch (params.contentType) { @@ -61,7 +64,8 @@ export default async function ContentTypePage({ case PageContentTypeEnum.destinationCountryPage: return case PageContentTypeEnum.destinationCityPage: - return + const filterFromUrl = searchParams.filterFromUrl + return case PageContentTypeEnum.hotelPage: if (env.HIDE_FOR_NEXT_RELEASE) { return notFound() diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx index 094ab8d47..61af72a69 100644 --- a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx @@ -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([]) + 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 } )} +
    {visibleHotels.map(({ hotel, url }) => ( diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx index 99fc897bd..504d58d87 100644 --- a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx @@ -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 ( - + - {intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })} + {getCityHeadingText(intl, city.name, allFilters, activeFilters[0])} - + ) } diff --git a/components/ContentType/DestinationPage/DestinationCityPage/DestinationCityPageSkeleton.tsx b/components/ContentType/DestinationPage/DestinationCityPage/DestinationCityPageSkeleton.tsx new file mode 100644 index 000000000..53da43e53 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/DestinationCityPageSkeleton.tsx @@ -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 ( +
    +
    + + +
    +
    + +
    + +
    + ) +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/index.tsx index 70d3eaa4d..a240586eb 100644 --- a/components/ContentType/DestinationPage/DestinationCityPage/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCityPage/index.tsx @@ -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 ( <> -
    -
    - }> - - - -
    -
    - }> - - - {blocks && } -
    - +
    + + + diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx new file mode 100644 index 000000000..333cc28d7 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx @@ -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 ( +
    +
    + + +
    + {children} +
    + ) +} diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/index.tsx b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/index.tsx new file mode 100644 index 000000000..15ef68571 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/index.tsx @@ -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(null) + useStickyPosition({ + ref: sidebarRef, + name: StickyElementNameEnum.DESTINATION_SIDEBAR, + }) + + return ( +
    + {children} +
    + ) +} diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/sidebarContentWrapper.module.css b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/sidebarContentWrapper.module.css new file mode 100644 index 000000000..f41dc74be --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCountryPage/SidebarContentWrapper/sidebarContentWrapper.module.css @@ -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; + } +} diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx b/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx index cfbfd92ab..81f4958d7 100644 --- a/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx @@ -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" diff --git a/components/ContentType/DestinationPage/ExperienceList/ExperienceListSkeleton.tsx b/components/ContentType/DestinationPage/ExperienceList/ExperienceListSkeleton.tsx index 1a35ccefb..745273e5a 100644 --- a/components/ContentType/DestinationPage/ExperienceList/ExperienceListSkeleton.tsx +++ b/components/ContentType/DestinationPage/ExperienceList/ExperienceListSkeleton.tsx @@ -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 (
      {Array.from({ length: 5 }).map((_, index) => ( diff --git a/components/ContentType/DestinationPage/HotelDataContainer/index.tsx b/components/ContentType/DestinationPage/HotelDataContainer/index.tsx new file mode 100644 index 000000000..7ce53c5c8 --- /dev/null +++ b/components/ContentType/DestinationPage/HotelDataContainer/index.tsx @@ -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 ( + + {children} + + ) +} diff --git a/components/ContentType/DestinationPage/HotelListing/Client.tsx b/components/ContentType/DestinationPage/HotelListing/Client.tsx deleted file mode 100644 index a2f703c4e..000000000 --- a/components/ContentType/DestinationPage/HotelListing/Client.tsx +++ /dev/null @@ -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(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 ( -
      -
      - - {intl.formatMessage( - { - id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`, - }, - { count: hotels.length } - )} - -
      -
        - {hotels.map(({ hotel, url }) => ( -
      • - -
      • - ))} -
      - {showToggleButton ? ( - - ) : null} - {showBackToTop && ( - - )} -
      - ) -} diff --git a/components/ContentType/DestinationPage/HotelListing/index.tsx b/components/ContentType/DestinationPage/HotelListing/index.tsx index e9769d47a..d99f940a7 100644 --- a/components/ContentType/DestinationPage/HotelListing/index.tsx +++ b/components/ContentType/DestinationPage/HotelListing/index.tsx @@ -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(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 + return ( +
      +
      + + {intl.formatMessage( + { + id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`, + }, + { count: activeHotels.length } + )} + + +
      +
        + {activeHotels.map(({ hotel, url }) => ( +
      • + +
      • + ))} +
      + {activeHotels.length > 5 ? ( + + ) : null} + {showBackToTop && ( + + )} +
      + ) } diff --git a/components/ContentType/DestinationPage/Map/index.tsx b/components/ContentType/DestinationPage/Map/index.tsx index f398928b7..8f25cfdf5 100644 --- a/components/ContentType/DestinationPage/Map/index.tsx +++ b/components/ContentType/DestinationPage/Map/index.tsx @@ -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) { + 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 ( diff --git a/components/ContentType/DestinationPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx b/components/ContentType/DestinationPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx new file mode 100644 index 000000000..333cc28d7 --- /dev/null +++ b/components/ContentType/DestinationPage/SidebarContentWrapper/SidebarContentWrapperSkeleton.tsx @@ -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 ( +
      +
      + + +
      + {children} +
      + ) +} diff --git a/components/ContentType/DestinationPage/SidebarContentWrapper/index.tsx b/components/ContentType/DestinationPage/SidebarContentWrapper/index.tsx index 15ef68571..2a3aae007 100644 --- a/components/ContentType/DestinationPage/SidebarContentWrapper/index.tsx +++ b/components/ContentType/DestinationPage/SidebarContentWrapper/index.tsx @@ -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(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 (
      + {headingText && {headingText}} {children}
      ) diff --git a/components/ContentType/DestinationPage/StaticMap/MapButton/index.tsx b/components/ContentType/DestinationPage/StaticMap/MapButton/index.tsx new file mode 100644 index 000000000..6baf5d569 --- /dev/null +++ b/components/ContentType/DestinationPage/StaticMap/MapButton/index.tsx @@ -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(null) + + useEffect(() => { + const url = new URL(window.location.href) + url.searchParams.set("view", "map") + setMapUrl(url.toString()) + }, [params]) + + if (!mapUrl) { + return null + } + + return ( + + ) +} diff --git a/components/ContentType/DestinationPage/StaticMap/index.tsx b/components/ContentType/DestinationPage/StaticMap/index.tsx index 243f1c7e4..ab5c86a31 100644 --- a/components/ContentType/DestinationPage/StaticMap/index.tsx +++ b/components/ContentType/DestinationPage/StaticMap/index.tsx @@ -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} /> - + ) } diff --git a/components/ContentType/DestinationPage/TopImages/TopImagesSkeleton.tsx b/components/ContentType/DestinationPage/TopImages/TopImagesSkeleton.tsx new file mode 100644 index 000000000..4f69b583b --- /dev/null +++ b/components/ContentType/DestinationPage/TopImages/TopImagesSkeleton.tsx @@ -0,0 +1,13 @@ +"use client" + +import SkeletonShimmer from "@/components/SkeletonShimmer" + +import styles from "./topImages.module.css" + +export default function TopImagesSkeleton() { + return ( +
      + +
      + ) +} diff --git a/components/ContentType/DestinationPage/utils.ts b/components/ContentType/DestinationPage/utils.ts new file mode 100644 index 000000000..f478d8518 --- /dev/null +++ b/components/ContentType/DestinationPage/utils.ts @@ -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 }) +} diff --git a/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css b/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css new file mode 100644 index 000000000..346e4a533 --- /dev/null +++ b/components/HotelFilterAndSort/Filter/Checkbox/checkbox.module.css @@ -0,0 +1,31 @@ +.checkboxWrapper { + display: flex; + align-items: center; + gap: var(--Spacing-x-one-and-half); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + cursor: pointer; + border-radius: var(--Corner-radius-Medium); + transition: background-color 0.3s; +} + +.checkboxWrapper:hover { + background-color: var(--UI-Input-Controls-Surface-Hover); +} + +.checkbox { + width: 24px; + height: 24px; + min-width: 24px; + border: 1px solid var(--UI-Input-Controls-Border-Normal); + border-radius: var(--Corner-radius-Small); + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--UI-Input-Controls-Surface-Normal); +} + +.checkboxWrapper[data-selected] .checkbox { + border-color: var(--UI-Input-Controls-Fill-Selected); + background-color: var(--UI-Input-Controls-Fill-Selected); +} diff --git a/components/HotelFilterAndSort/Filter/Checkbox/index.tsx b/components/HotelFilterAndSort/Filter/Checkbox/index.tsx new file mode 100644 index 000000000..d8a558f5a --- /dev/null +++ b/components/HotelFilterAndSort/Filter/Checkbox/index.tsx @@ -0,0 +1,41 @@ +"use client" + +import { Checkbox as AriaCheckbox } from "react-aria-components" + +import CheckIcon from "@/components/Icons/Check" +import Body from "@/components/TempDesignSystem/Text/Body" + +import styles from "./checkbox.module.css" + +interface CheckboxProps { + name: string + value: string + isSelected: boolean + onChange: (filterId: string) => void +} + +export default function Checkbox({ + isSelected, + name, + value, + onChange, +}: CheckboxProps) { + return ( + onChange(value)} + > + {({ isSelected }) => ( + <> + + {isSelected && } + + + {name} + + + )} + + ) +} diff --git a/components/HotelFilterAndSort/Filter/filter.module.css b/components/HotelFilterAndSort/Filter/filter.module.css new file mode 100644 index 000000000..34e91c7cf --- /dev/null +++ b/components/HotelFilterAndSort/Filter/filter.module.css @@ -0,0 +1,27 @@ +.container { + display: grid; + gap: var(--Spacing-x2); +} + +.form { + display: grid; + gap: var(--Spacing-x4); +} + +.fieldset { + border: none; + padding: 0; + display: grid; +} + +.fieldset:first-of-type { + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +.list { + display: grid; + grid-template-columns: repeat(3, 1fr); + list-style: none; + gap: var(--Spacing-x1) var(--Spacing-x2); + margin: var(--Spacing-x3) 0; +} diff --git a/components/HotelFilterAndSort/Filter/index.tsx b/components/HotelFilterAndSort/Filter/index.tsx new file mode 100644 index 000000000..2adb02719 --- /dev/null +++ b/components/HotelFilterAndSort/Filter/index.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useIntl } from "react-intl" + +import { useHotelDataStore } from "@/stores/hotel-data" + +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Title from "@/components/TempDesignSystem/Text/Title" + +import Checkbox from "./Checkbox" + +import styles from "./filter.module.css" + +import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort" + +interface FilterProps { + filters: CategorizedFilters +} + +export default function Filter({ filters }: FilterProps) { + const intl = useIntl() + const { facilityFilters, surroundingsFilters } = filters + const { pendingFilters, togglePendingFilter } = useHotelDataStore( + (state) => ({ + pendingFilters: state.pendingFilters, + togglePendingFilter: state.actions.togglePendingFilter, + }) + ) + + if (!facilityFilters.length && !surroundingsFilters.length) { + return null + } + + return ( +
      + {intl.formatMessage({ id: "Filter by" })} +
      +
      + + {intl.formatMessage({ id: "Hotel facilities" })} + +
        + {facilityFilters.map((filter) => ( +
      • + togglePendingFilter(filter.slug)} + isSelected={!!pendingFilters.find((f) => f === filter.slug)} + /> +
      • + ))} +
      +
      + +
      + + {intl.formatMessage({ id: "Hotel surroundings" })} + +
        + {surroundingsFilters.map((filter) => ( +
      • + togglePendingFilter(filter.slug)} + isSelected={!!pendingFilters.find((f) => f === filter.slug)} + /> +
      • + ))} +
      +
      +
      +
      + ) +} diff --git a/components/HotelFilterAndSort/Sort/index.tsx b/components/HotelFilterAndSort/Sort/index.tsx new file mode 100644 index 000000000..d203409d3 --- /dev/null +++ b/components/HotelFilterAndSort/Sort/index.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useIntl } from "react-intl" + +import { useHotelDataStore } from "@/stores/hotel-data" + +import Select from "@/components/TempDesignSystem/Select" + +import type { SortItem } from "@/types/components/hotelFilterAndSort" +import type { SortOption } from "@/types/enums/hotelFilterAndSort" + +interface SortProps { + sortItems: SortItem[] +} + +export default function Sort({ sortItems }: SortProps) { + const intl = useIntl() + const { pendingSort, setPendingSort } = useHotelDataStore((state) => ({ + pendingSort: state.pendingSort, + setPendingSort: state.actions.setPendingSort, + })) + + return ( +