Merged in feat/sw-2873-move-selecthotel-to-booking-flow (pull request #2727)
feat(SW-2873): Move select-hotel to booking flow * crude setup of select-hotel in partner-sas * wip * Fix linting * restructure tracking files * Remove dependency on trpc in tracking hooks * Move pageview tracking to common * Fix some lint and import issues * Add AlternativeHotelsPage * Add SelectHotelMapPage * Add AlternativeHotelsMapPage * remove next dependency in tracking store * Remove dependency on react in tracking hooks * move isSameBooking to booking-flow * Inject searchParamsComparator into tracking store * Move useTrackHardNavigation to common * Move useTrackSoftNavigation to common * Add TrackingSDK to partner-sas * call serverclient in layout * Remove unused css * Update types * Move HotelPin type * Fix todos * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Fix component Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
|
||||
import { SelectHotelSkeleton } from "@scandic-hotels/booking-flow/components/SelectHotel"
|
||||
|
||||
export default function AlternativeHotelsLoading() {
|
||||
return <SelectHotelSkeleton />
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { AlternativeHotelsMapPage as AlternativeHotelsMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsMapPage"
|
||||
|
||||
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
|
||||
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
|
||||
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
|
||||
import { MapContainer } from "@/components/MapContainer"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SelectHotelMapPage(
|
||||
export default async function AlternativeHotelsMapPage(
|
||||
props: PageArgs<LangParams, NextSearchParams>
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
|
||||
const booking = parseSelectHotelSearchParams(searchParams)
|
||||
|
||||
if (!booking) return notFound()
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<MapContainer>
|
||||
<Suspense
|
||||
key={searchParams.hotel}
|
||||
fallback={<SelectHotelMapContainerSkeleton />}
|
||||
>
|
||||
<SelectHotelMapContainer booking={booking} isAlternativeHotels />
|
||||
</Suspense>
|
||||
</MapContainer>
|
||||
<AlternativeHotelsMapPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { AlternativeHotelsPage as AlternativeHotelsPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsPage"
|
||||
|
||||
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { alternativeHotelsMap } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
|
||||
import { FamilyAndFriendsCodes } from "@/constants/booking"
|
||||
|
||||
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
|
||||
import SelectHotel from "@/components/HotelReservation/SelectHotel"
|
||||
import { getHotels } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
import { getSelectHotelTracking } from "@/components/HotelReservation/SelectHotel/tracking"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
type LangParams,
|
||||
@@ -26,112 +13,18 @@ export default async function AlternativeHotelsPage(
|
||||
props: PageArgs<LangParams, NextSearchParams>
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
const params = await props.params
|
||||
const lang = await getLang()
|
||||
|
||||
const booking = parseSelectHotelSearchParams(searchParams)
|
||||
|
||||
if (!booking) return notFound()
|
||||
|
||||
const searchDetails = await getHotelSearchDetails(booking, true)
|
||||
|
||||
if (!searchDetails || !searchDetails.hotel || !searchDetails.city) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
if (
|
||||
booking.bookingCode &&
|
||||
FamilyAndFriendsCodes.includes(booking.bookingCode)
|
||||
) {
|
||||
const cookieStore = await cookies()
|
||||
const isInvalidFNF = cookieStore.get("sc")?.value !== "1"
|
||||
|
||||
if (isInvalidFNF) {
|
||||
return <FnFNotAllowedAlert />
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This needs to be refactored into its
|
||||
// own functions
|
||||
const hotels = await getHotels({
|
||||
fromDate: booking.fromDate,
|
||||
toDate: booking.toDate,
|
||||
rooms: booking.rooms,
|
||||
isAlternativeFor: searchDetails.hotel,
|
||||
bookingCode: booking.bookingCode,
|
||||
city: searchDetails.city,
|
||||
redemption: !!searchDetails.redemption,
|
||||
})
|
||||
|
||||
const arrivalDate = new Date(booking.fromDate)
|
||||
const departureDate = new Date(booking.toDate)
|
||||
|
||||
const isRedemptionAvailability = searchDetails.redemption
|
||||
? hotels.some(
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
: false
|
||||
|
||||
const isBookingCodeRateAvailable = booking.bookingCode
|
||||
? hotels.some(
|
||||
(hotel) =>
|
||||
hotel.availability.bookingCode &&
|
||||
hotel.availability.status === "Available"
|
||||
)
|
||||
: false
|
||||
|
||||
const { hotelsTrackingData, pageTrackingData } = getSelectHotelTracking({
|
||||
lang: params.lang,
|
||||
pageId: searchDetails.hotel ? "alternative-hotels" : "select-hotel",
|
||||
pageName: searchDetails.hotel
|
||||
? "hotelreservation|alternative-hotels"
|
||||
: "hotelreservation|select-hotel",
|
||||
siteSections: searchDetails.hotel
|
||||
? "hotelreservation|alternative-hotels"
|
||||
: "hotelreservation|select-hotel",
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
rooms: booking.rooms,
|
||||
hotelsResult: hotels?.length ?? 0,
|
||||
searchTerm: searchDetails.hotel
|
||||
? booking.hotelId
|
||||
: searchDetails.cityIdentifier,
|
||||
country: hotels?.[0]?.hotel.address.country,
|
||||
hotelCity: hotels?.[0]?.hotel.address.city,
|
||||
bookingCode: booking.bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isRedemption: searchDetails.redemption,
|
||||
isRedemptionAvailable: isRedemptionAvailability,
|
||||
})
|
||||
|
||||
const mapHref = alternativeHotelsMap(params.lang)
|
||||
|
||||
const intl = await getIntl()
|
||||
const title = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Alternatives for {value}",
|
||||
},
|
||||
{
|
||||
value: searchDetails.hotel.name,
|
||||
}
|
||||
)
|
||||
const suspenseKey = stringify(searchParams)
|
||||
return (
|
||||
<>
|
||||
<SelectHotel
|
||||
bookingCode={booking.bookingCode}
|
||||
city={searchDetails.city}
|
||||
hotels={hotels}
|
||||
isAlternative={!!searchDetails.hotel}
|
||||
isBookingCodeRateAvailable={isBookingCodeRateAvailable}
|
||||
mapHref={mapHref}
|
||||
title={title}
|
||||
/>
|
||||
<Suspense key={`${suspenseKey}-tracking`} fallback={null}>
|
||||
<AlternativeHotelsPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import FnFNotAllowedAlert from "@scandic-hotels/booking-flow/components/FnFNotAllowedAlert"
|
||||
import { parseDetailsSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { FamilyAndFriendsCodes } from "@scandic-hotels/common/constants/familyAndFriends"
|
||||
|
||||
import { FamilyAndFriendsCodes } from "@/constants/booking"
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getHotel,
|
||||
@@ -19,7 +20,6 @@ import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
|
||||
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
|
||||
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
|
||||
import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDetails/Tracking"
|
||||
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
|
||||
import RoomProvider from "@/providers/Details/RoomProvider"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function HotelReservationPage(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
|
||||
import { SelectHotelSkeleton } from "@scandic-hotels/booking-flow/components/SelectHotel"
|
||||
|
||||
export default function SelectHotelLoading() {
|
||||
return <SelectHotelSkeleton />
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { SelectHotelMapPage as SelectHotelMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelMapPage"
|
||||
|
||||
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
|
||||
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
|
||||
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
|
||||
import { MapContainer } from "@/components/MapContainer"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
@@ -16,22 +11,20 @@ export default async function SelectHotelMapPage(
|
||||
props: PageArgs<LangParams, NextSearchParams>
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
const suspenseKey = stringify(searchParams)
|
||||
|
||||
const booking = parseSelectHotelSearchParams(searchParams)
|
||||
|
||||
if (!booking) return notFound()
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<MapContainer>
|
||||
<Suspense
|
||||
key={suspenseKey}
|
||||
fallback={<SelectHotelMapContainerSkeleton />}
|
||||
>
|
||||
<SelectHotelMapContainer booking={booking} />
|
||||
</Suspense>
|
||||
</MapContainer>
|
||||
<SelectHotelMapPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { SelectHotelPage as SelectHotelPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelPage"
|
||||
|
||||
import { parseSelectHotelSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { selectHotelMap } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
|
||||
import { FamilyAndFriendsCodes } from "@/constants/booking"
|
||||
|
||||
import FnFNotAllowedAlert from "@/components/HotelReservation/FnFNotAllowedAlert/FnFNotAllowedAlert"
|
||||
import SelectHotel from "@/components/HotelReservation/SelectHotel"
|
||||
import { getHotels } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
import { getSelectHotelTracking } from "@/components/HotelReservation/SelectHotel/tracking"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||
|
||||
@@ -21,93 +9,18 @@ export default async function SelectHotelPage(
|
||||
props: PageArgs<LangParams, NextSearchParams>
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
const params = await props.params
|
||||
const lang = await getLang()
|
||||
|
||||
const booking = parseSelectHotelSearchParams(searchParams)
|
||||
|
||||
if (!booking) return notFound()
|
||||
|
||||
const searchDetails = await getHotelSearchDetails(booking)
|
||||
|
||||
if (!searchDetails || !searchDetails.city) return notFound()
|
||||
|
||||
if (
|
||||
booking.bookingCode &&
|
||||
FamilyAndFriendsCodes.includes(booking.bookingCode)
|
||||
) {
|
||||
const cookieStore = await cookies()
|
||||
const isInvalidFNF = cookieStore.get("sc")?.value !== "1"
|
||||
|
||||
if (isInvalidFNF) {
|
||||
return <FnFNotAllowedAlert />
|
||||
}
|
||||
}
|
||||
|
||||
const { city, redemption } = searchDetails
|
||||
|
||||
const hotels = await getHotels({
|
||||
fromDate: booking.fromDate,
|
||||
toDate: booking.toDate,
|
||||
rooms: booking.rooms,
|
||||
isAlternativeFor: null,
|
||||
bookingCode: booking.bookingCode,
|
||||
city: city,
|
||||
redemption: !!redemption,
|
||||
})
|
||||
|
||||
const isRedemptionAvailability = redemption
|
||||
? hotels.some(
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
: false
|
||||
|
||||
const isBookingCodeRateAvailable = booking.bookingCode
|
||||
? hotels.some(
|
||||
(hotel) =>
|
||||
hotel.availability.bookingCode &&
|
||||
hotel.availability.status === "Available"
|
||||
)
|
||||
: false
|
||||
|
||||
const arrivalDate = new Date(booking.fromDate)
|
||||
const departureDate = new Date(booking.toDate)
|
||||
|
||||
const { hotelsTrackingData, pageTrackingData } = getSelectHotelTracking({
|
||||
rooms: booking.rooms,
|
||||
lang: params.lang,
|
||||
pageId: "select-hotel",
|
||||
pageName: "hotelreservation|select-hotel",
|
||||
siteSections: "hotelreservation|select-hotel",
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
hotelsResult: hotels?.length ?? 0,
|
||||
searchTerm: booking.hotelId,
|
||||
country: hotels?.[0]?.hotel.address.country,
|
||||
hotelCity: hotels?.[0]?.hotel.address.city,
|
||||
bookingCode: booking.bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isRedemption: redemption,
|
||||
isRedemptionAvailable: isRedemptionAvailability,
|
||||
})
|
||||
|
||||
const mapHref = selectHotelMap(params.lang)
|
||||
const suspenseKey = stringify(searchParams)
|
||||
return (
|
||||
<>
|
||||
<SelectHotel
|
||||
bookingCode={booking.bookingCode}
|
||||
isBookingCodeRateAvailable={isBookingCodeRateAvailable}
|
||||
city={city}
|
||||
hotels={hotels}
|
||||
mapHref={mapHref}
|
||||
title={city.name}
|
||||
/>
|
||||
<Suspense key={`${suspenseKey}-tracking`} fallback={null}>
|
||||
<SelectHotelPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
hotelInfo={hotelsTrackingData}
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
export default async function Tracking() {
|
||||
const lang = await getLang()
|
||||
|
||||
@@ -9,13 +9,13 @@ import Script from "next/script"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
|
||||
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { ToastHandler } from "@scandic-hotels/design-system/ToastHandler"
|
||||
|
||||
import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
|
||||
import { BookingFlowProviders } from "@/components/BookingFlowProviders"
|
||||
import CookieBotConsent from "@/components/CookieBot"
|
||||
import Footer from "@/components/Footer"
|
||||
import Header from "@/components/Header"
|
||||
@@ -30,8 +30,6 @@ import { FontPreload } from "@/fonts/font-preloading"
|
||||
import { getMessages } from "@/i18n"
|
||||
import ClientIntlProvider from "@/i18n/Provider"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import { trackAccordionClick, trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||
import { trackBookingSearchClick } from "@/utils/tracking/booking"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
@@ -72,13 +70,7 @@ export default async function RootLayout(
|
||||
<NuqsAdapter>
|
||||
<TrpcProvider>
|
||||
<RACRouterProvider>
|
||||
<BookingFlowTrackingProvider
|
||||
trackingFunctions={{
|
||||
trackBookingSearchClick,
|
||||
trackAccordionItemOpen: trackAccordionClick,
|
||||
trackOpenSidePeek: trackOpenSidePeekEvent,
|
||||
}}
|
||||
>
|
||||
<BookingFlowProviders>
|
||||
<RouteChange />
|
||||
<SitewideAlert />
|
||||
<Header />
|
||||
@@ -91,7 +83,7 @@ export default async function RootLayout(
|
||||
<CookieBotConsent />
|
||||
<UserExists />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</BookingFlowTrackingProvider>
|
||||
</BookingFlowProviders>
|
||||
</RACRouterProvider>
|
||||
</TrpcProvider>
|
||||
</NuqsAdapter>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
export default async function Tracking() {
|
||||
const lang = await getLang()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import NextAuth, { type NextAuthConfig, type User } from "next-auth"
|
||||
|
||||
import { LoginTypeEnum } from "@scandic-hotels/common/constants/loginType"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { LoginTypeEnum } from "@scandic-hotels/trpc/types/loginType"
|
||||
|
||||
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
|
||||
import { env } from "@/env/server"
|
||||
|
||||
30
apps/scandic-web/components/BookingFlowProviders.tsx
Normal file
30
apps/scandic-web/components/BookingFlowProviders.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
|
||||
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
import { trackAccordionClick, trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||
import { trackBookingSearchClick } from "@/utils/tracking/booking"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export function BookingFlowProviders({ children }: { children: ReactNode }) {
|
||||
const isLoggedIn = useIsUserLoggedIn()
|
||||
|
||||
return (
|
||||
<BookingFlowContextProvider data={{ isLoggedIn }}>
|
||||
<BookingFlowTrackingProvider
|
||||
trackingFunctions={{
|
||||
trackBookingSearchClick,
|
||||
trackAccordionItemOpen: trackAccordionClick,
|
||||
trackOpenSidePeek: trackOpenSidePeekEvent,
|
||||
trackGenericEvent: trackEvent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BookingFlowTrackingProvider>
|
||||
</BookingFlowContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useEffect } from "react"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { trackOpenMapView } from "@/utils/tracking/destinationPage"
|
||||
|
||||
import type { TrackingSDKPageData } from "@/types/components/tracking"
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
interface DestinationTrackingProps {
|
||||
pageData: TrackingSDKPageData
|
||||
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
} from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelsMapStore } from "@scandic-hotels/booking-flow/stores/hotels-map"
|
||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
|
||||
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
import { PointOfInterestGroupEnum } from "@scandic-hotels/trpc/enums/pointOfInterest"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
@@ -15,11 +20,6 @@ import type {
|
||||
HotelPageSectionHeadings,
|
||||
HotelPageSections,
|
||||
} from "@/types/components/hotelPage/sections"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import { HotelHashValues } from "@/types/enums/hotelPage"
|
||||
|
||||
export function getRoomNameAsParam(roomName: string) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/common/tracking/types"
|
||||
import type { CollectionPage } from "@scandic-hotels/trpc/types/collectionPage"
|
||||
import type { ContentPage } from "@scandic-hotels/trpc/types/contentPage"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
import type { TrackingSDKPageData } from "@/types/components/tracking"
|
||||
import type { staticPageVariants } from "./variants"
|
||||
|
||||
export interface StaticPageProps
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SiteSectionObject,
|
||||
TrackingData,
|
||||
TrackingProps,
|
||||
} from "@/types/components/tracking"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
function createPageObject(trackingData: TrackingData) {
|
||||
const englishSegments = trackingData.englishUrl
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/hooks"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/useFormTracking"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { getFormattedCountryList } from "@/utils/countries"
|
||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
|
||||
@@ -2,8 +2,15 @@ import { createHash } from "crypto"
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKAncillaries,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
type TrackingSDKPaymentInfo,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { CancellationRuleEnum } from "@/constants/booking"
|
||||
@@ -17,13 +24,6 @@ import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
||||
import type { RateDefinition } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKAncillaries,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
type TrackingSDKPaymentInfo,
|
||||
} from "@/types/components/tracking"
|
||||
import type { Room } from "@/types/stores/booking-confirmation"
|
||||
|
||||
function getRate(cancellationRule: RateDefinition["cancellationRule"] | null) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/hooks"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/useFormTracking"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import usePhoneNumberParsing from "@/hooks/usePhoneNumberParsing"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/hooks"
|
||||
import { useFormTracking } from "@/components/TrackingSDK/useFormTracking"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import usePhoneNumberParsing from "@/hooks/usePhoneNumberParsing"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HotelDetailsSidePeek } from "@scandic-hotels/booking-flow/components/HotelDetailsSidePeek"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKAncillaries,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { PackageTypeEnum } from "@scandic-hotels/trpc/enums/packages"
|
||||
@@ -22,12 +28,6 @@ import type {
|
||||
DetailsBooking,
|
||||
RoomRate,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKAncillaries,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
|
||||
export function getTracking(
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.fnfMain {
|
||||
max-width: var(--max-width-page);
|
||||
margin: auto;
|
||||
min-height: 30dvh;
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./FnFNotAllowedAlert.module.css"
|
||||
|
||||
export default async function FnFNotAllowedAlert() {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.fnfMain}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Warning}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"The Friends & Family rate can only be booked via FUSE.",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon"
|
||||
import { HotelCardDialogImage } from "@scandic-hotels/design-system/HotelCard/HotelCardDialogImage"
|
||||
import { HotelPointsRow } from "@scandic-hotels/design-system/HotelCard/HotelPointsRow"
|
||||
import { NoPriceAvailableCard } from "@scandic-hotels/design-system/HotelCard/NoPriceAvailableCard"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import styles from "./listingHotelCardDialog.module.css"
|
||||
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
interface ListingHotelCardProps {
|
||||
data: HotelPin
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export default function ListingHotelCardDialog({
|
||||
data,
|
||||
handleClose,
|
||||
}: ListingHotelCardProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const {
|
||||
bookingCode,
|
||||
name,
|
||||
publicPrice,
|
||||
memberPrice,
|
||||
currency,
|
||||
amenities,
|
||||
images,
|
||||
ratings,
|
||||
operaId,
|
||||
redemptionPrice,
|
||||
chequePrice,
|
||||
voucherPrice,
|
||||
hasEnoughPoints,
|
||||
} = data
|
||||
const firstImage = images[0]?.imageSizes?.small
|
||||
const altText = images[0]?.metaData?.altText
|
||||
|
||||
const notEnoughPointsLabel = intl.formatMessage({
|
||||
defaultMessage: "Not enough points",
|
||||
})
|
||||
const shouldShowNotEnoughPoints = redemptionPrice && !hasEnoughPoints
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
className={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
>
|
||||
<MaterialIcon icon="close" size={22} color="CurrentColor" />
|
||||
</IconButton>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<HotelCardDialogImage
|
||||
firstImage={firstImage}
|
||||
altText={altText}
|
||||
rating={{ tripAdvisor: ratings }}
|
||||
imageError={imageError}
|
||||
setImageError={setImageError}
|
||||
position="top"
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.name}>
|
||||
<Subtitle type="two">{name}</Subtitle>
|
||||
</div>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => (
|
||||
<div key={facility.id}>
|
||||
<FacilityToIcon
|
||||
id={facility.id}
|
||||
size={20}
|
||||
color="Icon/Default"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publicPrice ||
|
||||
memberPrice ||
|
||||
redemptionPrice ||
|
||||
voucherPrice ||
|
||||
chequePrice ? (
|
||||
<div className={styles.bottomContainer}>
|
||||
<div className={styles.pricesContainer}>
|
||||
{redemptionPrice ? (
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Available rates",
|
||||
})}
|
||||
</Caption>
|
||||
) : (
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Per night from",
|
||||
})}
|
||||
</Caption>
|
||||
)}
|
||||
<div className={styles.listingPrices}>
|
||||
{publicPrice && !isUserLoggedIn && memberPrice ? (
|
||||
<>
|
||||
<Subtitle type="two">
|
||||
{publicPrice} {currency}
|
||||
</Subtitle>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{memberPrice && <Caption>/</Caption>}
|
||||
</>
|
||||
) : (
|
||||
bookingCode &&
|
||||
publicPrice && (
|
||||
<Subtitle type="two" color="red">
|
||||
{publicPrice} {currency}
|
||||
</Subtitle>
|
||||
)
|
||||
)}
|
||||
{memberPrice && (
|
||||
<Subtitle type="two" color="red">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
},
|
||||
{
|
||||
price: memberPrice,
|
||||
currency,
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
)}
|
||||
{redemptionPrice && (
|
||||
<HotelPointsRow pointsPerStay={redemptionPrice} />
|
||||
)}
|
||||
{chequePrice && (
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
},
|
||||
{
|
||||
price: chequePrice.numberOfCheques,
|
||||
currency: "CC",
|
||||
}
|
||||
)}
|
||||
{chequePrice.additionalPricePerStay > 0
|
||||
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
" + " +
|
||||
intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
},
|
||||
{
|
||||
price: chequePrice.additionalPricePerStay,
|
||||
currency: chequePrice.currency,
|
||||
}
|
||||
)
|
||||
: null}
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<span>
|
||||
/
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Subtitle>
|
||||
)}
|
||||
{voucherPrice && (
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
},
|
||||
{
|
||||
price: voucherPrice,
|
||||
currency,
|
||||
}
|
||||
)}
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<span>
|
||||
/
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Subtitle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowNotEnoughPoints ? (
|
||||
<div className={styles.notEnoughPointsButton}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{notEnoughPointsLabel}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See rooms",
|
||||
})}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NoPriceAvailableCard />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
.container {
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
min-width: 358px;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--Space-x15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.name {
|
||||
height: 48px;
|
||||
max-width: 180px;
|
||||
margin-bottom: var(--Space-x05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
display: flex;
|
||||
gap: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.priceCard {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
padding: var(--Space-x05) var(--Space-x1);
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
margin-top: var(--Space-x1);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bottomContainer {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-top: var(--Space-x2);
|
||||
padding-bottom: var(--Space-x05);
|
||||
}
|
||||
|
||||
.pricesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.listingPrices {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.content .button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.notEnoughPointsButton {
|
||||
border-radius: var(--Corner-radius-rounded);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--Space-x05);
|
||||
|
||||
padding: 10px var(--Space-x2);
|
||||
background-color: var(--Component-Button-Brand-Primary-Fill-Disabled);
|
||||
border-color: var(--Component-Button-Brand-Primary-Border-Disabled);
|
||||
color: var(--Component-Button-Brand-Primary-On-fill-Disabled);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.hotelCardDialogListing {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: flex-end;
|
||||
overflow-x: scroll;
|
||||
|
||||
scroll-snap-type: x proximity;
|
||||
-webkit-overflow-scrolling: touch; /* Needed to work on iOS Safari */
|
||||
padding-inline: var(--Spacing-x2);
|
||||
scroll-padding-inline: var(--Spacing-x2);
|
||||
overscroll-behavior-inline: contain;
|
||||
|
||||
scroll-behavior: smooth;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.hotelCard {
|
||||
height: 100%;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import ListingHotelCardDialog from "../HotelCardDialog/ListingHotelCardDialog"
|
||||
import { getHotelPins } from "./utils"
|
||||
|
||||
import styles from "./hotelCardDialogListing.module.css"
|
||||
|
||||
import type { HotelCardDialogListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialogListing({
|
||||
hotels,
|
||||
unfilteredHotelCount,
|
||||
}: HotelCardDialogListingProps) {
|
||||
const intl = useIntl()
|
||||
const isRedemption = hotels?.find(
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
const currencyValue = isRedemption
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Points",
|
||||
})
|
||||
: undefined
|
||||
const hotelsPinData = getHotelPins(hotels, currencyValue)
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const isScrollingRef = useRef<boolean>(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { activeHotel, activate, deactivate } = useHotelsMapStore()
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
// skip intersection handling during scrolling
|
||||
if (isScrollingRef.current) return
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const cardName = entry.target.getAttribute("data-name")
|
||||
if (cardName && cardName !== activeHotel) {
|
||||
activate(cardName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[activate, activeHotel]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
threshold: [0.3, 0.5, 0.7],
|
||||
})
|
||||
|
||||
const elements = document.querySelectorAll("[data-name]")
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
elements.forEach((el) => observerRef.current?.unobserve(el))
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
}, [handleIntersection])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCardRef.current) {
|
||||
isScrollingRef.current = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
activeCardRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
}, [activeHotel])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMapClick = (e: MouseEvent) => {
|
||||
// ignore clicks within the dialog
|
||||
if (dialogRef.current?.contains(e.target as Node)) {
|
||||
return
|
||||
}
|
||||
|
||||
// ignore clicks on hotel pins
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-hotelpin]")) {
|
||||
return
|
||||
}
|
||||
|
||||
deactivate()
|
||||
}
|
||||
|
||||
if (activeHotel) {
|
||||
document.addEventListener("click", handleMapClick)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleMapClick)
|
||||
}
|
||||
}, [dialogRef, activeHotel, deactivate])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels.length, unfilteredHotelCount)
|
||||
}, [hotels, setResultCount, unfilteredHotelCount])
|
||||
|
||||
return (
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{hotelsPinData?.map((data) => {
|
||||
const isActive = data.name === activeHotel
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
ref={isActive ? activeCardRef : null}
|
||||
data-name={data.name}
|
||||
className={styles.hotelCard}
|
||||
>
|
||||
<ListingHotelCardDialog data={data} handleClose={deactivate} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
|
||||
export function getHotelPins(
|
||||
hotels: HotelResponse[],
|
||||
currencyValue?: string
|
||||
): HotelPin[] {
|
||||
if (!hotels.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return hotels.map(({ availability, hotel, additionalData }) => {
|
||||
const productType = availability.productType
|
||||
const redemptionRate = productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)
|
||||
const chequePrice = productType?.bonusCheque?.localPrice
|
||||
const voucherPrice = productType?.voucher?.numberOfVouchers
|
||||
if (chequePrice || voucherPrice) {
|
||||
currencyValue = chequePrice ? "CC" : "Voucher"
|
||||
}
|
||||
return {
|
||||
bookingCode: availability.bookingCode,
|
||||
coordinates: {
|
||||
lat: hotel.location.latitude,
|
||||
lng: hotel.location.longitude,
|
||||
},
|
||||
name: hotel.name,
|
||||
chequePrice: chequePrice ?? null,
|
||||
publicPrice: productType?.public?.localPrice.pricePerNight ?? null,
|
||||
memberPrice: productType?.member?.localPrice.pricePerNight ?? null,
|
||||
redemptionPrice: redemptionRate?.localPrice.pointsPerStay ?? null,
|
||||
voucherPrice: voucherPrice ?? null,
|
||||
rateType:
|
||||
productType?.public?.rateType ?? productType?.member?.rateType ?? null,
|
||||
currency:
|
||||
productType?.public?.localPrice.currency ||
|
||||
productType?.member?.localPrice.currency ||
|
||||
currencyValue ||
|
||||
"N/A",
|
||||
images: [
|
||||
hotel.hotelContent.images,
|
||||
...(additionalData.gallery?.heroImages ?? []),
|
||||
],
|
||||
amenities: hotel.detailedFacilities
|
||||
.map((facility) => ({
|
||||
...facility,
|
||||
icon: facility.icon ?? "None",
|
||||
}))
|
||||
.slice(0, 5),
|
||||
ratings: hotel.ratings?.tripAdvisor.rating ?? null,
|
||||
operaId: hotel.operaId,
|
||||
facilityIds: hotel.detailedFacilities.map((facility) => facility.id),
|
||||
hasEnoughPoints: !!availability.productType?.redemptions?.some(
|
||||
(r) => r.hasEnoughPoints
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
.hotelCards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BookingCodeFilterEnum,
|
||||
useBookingCodeFilterStore,
|
||||
} from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import {
|
||||
alternativeHotelsMap,
|
||||
selectHotelMap,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||
import { HotelCard } from "@scandic-hotels/design-system/HotelCard"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
import { getSortedHotels } from "./utils"
|
||||
|
||||
import styles from "./hotelCardListing.module.css"
|
||||
|
||||
import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
|
||||
|
||||
import {
|
||||
type HotelCardListingProps,
|
||||
HotelCardListingTypeEnum,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
unfilteredHotelCount,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
isAlternative,
|
||||
}: HotelCardListingProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const { activeHotel, activate, disengage, engage } = useHotelsMapStore()
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
|
||||
|
||||
const bookingCode = searchParams.get("bookingCode")
|
||||
// Special rates (corporate cheque, voucher) will not show regular rate hotels availability
|
||||
const isSpecialRate = bookingCode
|
||||
? hotelData.find(
|
||||
(hotel) =>
|
||||
hotel.availability.productType?.bonusCheque ||
|
||||
hotel.availability.productType?.voucher
|
||||
)
|
||||
: false
|
||||
const activeCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.activeCodeFilter
|
||||
)
|
||||
const isBookingCodeRateAvailable =
|
||||
bookingCode && !isSpecialRate
|
||||
? hotelData.some((hotel) => hotel.availability.bookingCode)
|
||||
: false
|
||||
const showOnlyBookingCodeRates =
|
||||
isBookingCodeRateAvailable &&
|
||||
activeCodeFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
const sortedHotels = getSortedHotels({
|
||||
hotels: hotelData,
|
||||
sortBy,
|
||||
bookingCode: isSpecialRate ? null : bookingCode,
|
||||
})
|
||||
|
||||
const updatedHotelsList = showOnlyBookingCodeRates
|
||||
? sortedHotels.filter((hotel) => hotel.availability.bookingCode)
|
||||
: sortedHotels
|
||||
|
||||
if (!activeFilters.length) {
|
||||
return updatedHotelsList
|
||||
}
|
||||
|
||||
return updatedHotelsList.filter((hotel) =>
|
||||
activeFilters.every((appliedFilterId) =>
|
||||
hotel.hotel.detailedFacilities.some(
|
||||
(facility) => facility.id.toString() === appliedFilterId
|
||||
)
|
||||
)
|
||||
)
|
||||
}, [
|
||||
activeFilters,
|
||||
bookingCode,
|
||||
hotelData,
|
||||
sortBy,
|
||||
showOnlyBookingCodeRates,
|
||||
isSpecialRate,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCardRef.current && type === HotelCardListingTypeEnum.MapListing) {
|
||||
activeCardRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
})
|
||||
}
|
||||
}, [activeHotel, type])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels.length, unfilteredHotelCount)
|
||||
}, [hotels, setResultCount, unfilteredHotelCount])
|
||||
|
||||
function isHotelActiveInMapView(hotelName: string): boolean {
|
||||
return (
|
||||
hotelName === activeHotel && type === HotelCardListingTypeEnum.MapListing
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.hotel.operaId}
|
||||
ref={isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null}
|
||||
data-active={
|
||||
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
|
||||
}
|
||||
>
|
||||
<HotelCard
|
||||
hotel={{
|
||||
id: hotel.hotel.operaId,
|
||||
name: hotel.hotel.name,
|
||||
address: hotel.hotel.address,
|
||||
description: hotel.hotel.hotelContent.texts.descriptions?.short,
|
||||
hotelType: hotel.hotel.hotelType as HotelType,
|
||||
detailedFacilities: hotel.hotel.detailedFacilities,
|
||||
ratings: {
|
||||
tripAdvisor: hotel.hotel.ratings?.tripAdvisor.rating,
|
||||
},
|
||||
}}
|
||||
lang={lang}
|
||||
fullPrice={!hotel.availability.bookingCode}
|
||||
prices={
|
||||
hotel.availability.productType && {
|
||||
public: hotel.availability.productType?.public
|
||||
? {
|
||||
...hotel.availability.productType.public,
|
||||
requestedPrice:
|
||||
hotel.availability.productType?.public.requestedPrice ??
|
||||
undefined,
|
||||
}
|
||||
: undefined,
|
||||
member: hotel.availability.productType?.member
|
||||
? {
|
||||
...hotel.availability.productType.member,
|
||||
requestedPrice:
|
||||
hotel.availability.productType?.member.requestedPrice ??
|
||||
undefined,
|
||||
}
|
||||
: undefined,
|
||||
voucher: hotel.availability.productType?.voucher,
|
||||
bonusCheque: hotel.availability.productType?.bonusCheque
|
||||
? {
|
||||
...hotel.availability.productType.bonusCheque,
|
||||
requestedPrice:
|
||||
hotel.availability.productType.bonusCheque
|
||||
.requestedPrice ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
redemptions: hotel.availability.productType?.redemptions?.map(
|
||||
(redemption) => ({
|
||||
...redemption,
|
||||
localPrice: {
|
||||
...redemption.localPrice,
|
||||
currency: redemption.localPrice.currency,
|
||||
},
|
||||
})
|
||||
),
|
||||
}
|
||||
}
|
||||
onHover={() => engage(hotel.hotel.name)}
|
||||
onHoverEnd={() => disengage()}
|
||||
onAddressClick={() => {
|
||||
const mapUrl = isAlternative
|
||||
? alternativeHotelsMap(lang)
|
||||
: selectHotelMap(lang)
|
||||
|
||||
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
|
||||
activate(hotel.hotel.name)
|
||||
router.push(`${mapUrl}?${searchParams.toString()}`)
|
||||
}}
|
||||
belowInfoSlot={
|
||||
<HotelDetailsSidePeek
|
||||
hotel={{ ...hotel.hotel, url: hotel.url }}
|
||||
restaurants={hotel.restaurants}
|
||||
additionalHotelData={hotel.additionalData}
|
||||
triggerLabel={intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
}
|
||||
distanceToCityCenter={hotel.hotel.location.distanceToCentre}
|
||||
images={mapApiImagesToGalleryImages(hotel.hotel.galleryImages)}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
state={
|
||||
isHotelActiveInMapView(hotel.hotel.name) ? "active" : "default"
|
||||
}
|
||||
type={type}
|
||||
bookingCode={bookingCode}
|
||||
isAlternative={isAlternative}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton
|
||||
position="right"
|
||||
onClick={scrollToTop}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Back to top",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
|
||||
function getPricePerNight(hotel: HotelResponse): number {
|
||||
return (
|
||||
hotel.availability.productType?.member?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.public?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)?.localPrice?.pointsPerStay ??
|
||||
Infinity
|
||||
)
|
||||
}
|
||||
|
||||
export function getSortedHotels({
|
||||
hotels,
|
||||
sortBy,
|
||||
bookingCode,
|
||||
}: {
|
||||
hotels: HotelResponse[]
|
||||
sortBy: string
|
||||
bookingCode: string | null
|
||||
}) {
|
||||
const availableHotels = hotels.filter(
|
||||
(hotel) => !!hotel.availability.productType
|
||||
)
|
||||
const unavailableHotels = hotels.filter(
|
||||
(hotel) => !hotel.availability.productType
|
||||
)
|
||||
|
||||
const sortingStrategies: Record<
|
||||
string,
|
||||
(a: HotelResponse, b: HotelResponse) => number
|
||||
> = {
|
||||
[SortOrder.Name]: (a: HotelResponse, b: HotelResponse) =>
|
||||
a.hotel.name.localeCompare(b.hotel.name),
|
||||
[SortOrder.TripAdvisorRating]: (a: HotelResponse, b: HotelResponse) =>
|
||||
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
|
||||
(a.hotel.ratings?.tripAdvisor.rating ?? 0),
|
||||
[SortOrder.Price]: (a: HotelResponse, b: HotelResponse) =>
|
||||
getPricePerNight(a) - getPricePerNight(b),
|
||||
[SortOrder.Distance]: (a: HotelResponse, b: HotelResponse) =>
|
||||
a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre,
|
||||
}
|
||||
|
||||
const sortStrategy =
|
||||
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
|
||||
|
||||
if (bookingCode) {
|
||||
const bookingCodeRateHotels = availableHotels.filter(
|
||||
(hotel) => hotel.availability.bookingCode
|
||||
)
|
||||
const regularRateHotels = availableHotels.filter(
|
||||
(hotel) => !hotel.availability.bookingCode
|
||||
)
|
||||
|
||||
return bookingCodeRateHotels
|
||||
.sort(sortStrategy)
|
||||
.concat(regularRateHotels.sort(sortStrategy))
|
||||
.concat(unavailableHotels.sort(sortStrategy))
|
||||
}
|
||||
|
||||
return availableHotels
|
||||
.sort(sortStrategy)
|
||||
.concat(unavailableHotels.sort(sortStrategy))
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
export default async function Tracking() {
|
||||
const lang = await getLang()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
@@ -11,7 +12,6 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import Modal from "@scandic-hotels/design-system/Modal"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { CancellationRuleEnum } from "@/constants/booking"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Modal from "@scandic-hotels/design-system/Modal"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
|
||||
import { CancellationRuleEnum } from "@/constants/booking"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { convertToChildType } from "../../utils/convertToChildType"
|
||||
import { getPriceType } from "../../utils/getPriceType"
|
||||
import { formatChildBedPreferences } from "../utils"
|
||||
|
||||
import type { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
||||
import type { Room } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
.card {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.priceVariants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import styles from "./RoomCardSkeleton.module.css"
|
||||
|
||||
export function RoomCardSkeleton() {
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{/* image container */}
|
||||
<div className={styles.imageContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
|
||||
<div className={styles.priceVariants}>
|
||||
{/* price variants */}
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<SkeletonShimmer key={index} height={"100px"} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes modal-slide-up {
|
||||
from {
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
height: var(--visual-viewport-height);
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 100;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: calc(100dvh - 20px);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
width: 100%;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-slide-up 200ms;
|
||||
}
|
||||
|
||||
&[data-existing] {
|
||||
animation: modal-slide-up 200ms reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x-half)
|
||||
var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--Base-Text-Accent);
|
||||
border-radius: var(--Corner-radius-xl);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: var(--Spacing-x2);
|
||||
padding-top: calc(var(--Spacing-x3) + var(--Spacing-x-half));
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
margin-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filters ul li {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modal {
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
height: min(80dvh, 680px);
|
||||
width: min(80dvw, 960px);
|
||||
translate: -50% 50%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
padding: 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Space-x1);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
border-top-left-radius: var(--Corner-radius-lg);
|
||||
border-top-right-radius: var(--Corner-radius-lg);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--Spacing-x4);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
overflow-y: unset;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.sorter,
|
||||
.filters,
|
||||
.footer,
|
||||
.divider {
|
||||
padding: 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
border-bottom-left-radius: var(--Corner-radius-lg);
|
||||
border-bottom-right-radius: var(--Corner-radius-lg);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filters aside > form {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filters aside form > div:last-child {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filters aside ul {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.filters ul li:hover {
|
||||
background: var(--UI-Input-Controls-Surface-Hover);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
outline: none;
|
||||
}
|
||||
.filters ul li {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1024) {
|
||||
.facilities ul {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
usePathname,
|
||||
useSearchParams,
|
||||
} from "next/dist/client/components/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
|
||||
|
||||
import { DEFAULT_SORT } from "../../HotelSorter"
|
||||
import FilterContent from "../FilterContent"
|
||||
|
||||
import styles from "./filterAndSortModal.module.css"
|
||||
|
||||
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
|
||||
import {
|
||||
type SortItem,
|
||||
SortOrder,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
export default function FilterAndSortModal({
|
||||
filters,
|
||||
setShowSkeleton,
|
||||
}: FilterAndSortModalProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
useInitializeFiltersFromUrl()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const { resultCount, setFilters, activeFilters, unfilteredResultCount } =
|
||||
useHotelFilterStore((state) => ({
|
||||
resultCount: state.resultCount,
|
||||
setFilters: state.setFilters,
|
||||
activeFilters: state.activeFilters,
|
||||
unfilteredResultCount: state.unfilteredResultCount,
|
||||
}))
|
||||
|
||||
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
|
||||
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<string[]>(activeFilters)
|
||||
|
||||
const [filteredCount, setFilteredCount] = useState(resultCount)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFilters.length) {
|
||||
setSelectedFilters(activeFilters)
|
||||
}
|
||||
}, [activeFilters])
|
||||
|
||||
const sortItems: SortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Distance to city center",
|
||||
}),
|
||||
value: SortOrder.Distance,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Name",
|
||||
}),
|
||||
value: SortOrder.Name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Price",
|
||||
}),
|
||||
value: SortOrder.Price,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "TripAdvisor rating",
|
||||
}),
|
||||
value: SortOrder.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
const handleSortSelect = useCallback((value: string | number) => {
|
||||
setSort(value.toString())
|
||||
}, [])
|
||||
|
||||
const handleApplyFiltersAndSorting = useCallback(
|
||||
(close: () => void) => {
|
||||
setFilters(selectedFilters)
|
||||
|
||||
if (setShowSkeleton) {
|
||||
setShowSkeleton(true)
|
||||
}
|
||||
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
|
||||
const values = selectedFilters.join(",")
|
||||
if (values === "") {
|
||||
newSearchParams.delete("filters")
|
||||
} else {
|
||||
newSearchParams.set("filters", values)
|
||||
}
|
||||
|
||||
newSearchParams.set("sort", sort)
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
close()
|
||||
if (setShowSkeleton) {
|
||||
setTimeout(() => {
|
||||
setShowSkeleton(false)
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[pathname, searchParams, sort, setShowSkeleton, selectedFilters, setFilters]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger>
|
||||
<Button variant="Secondary" size="Small" color="Primary">
|
||||
<MaterialIcon icon="filter_alt" color="CurrentColor" />
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Filter and sort",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
{activeFilters.length > 0 && (
|
||||
<Typography variant="Label/xsRegular" className={styles.badge}>
|
||||
<p>{activeFilters.length}</p>
|
||||
</Typography>
|
||||
)}
|
||||
</Button>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<AriaDialog role="alertdialog" className={styles.content}>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<Typography
|
||||
variant="Title/Subtitle/md"
|
||||
className={styles.title}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Filter and sort",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={close}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
>
|
||||
<MaterialIcon icon="close" />
|
||||
</IconButton>
|
||||
</header>
|
||||
<div className={styles.sorter}>
|
||||
<DeprecatedSelect
|
||||
items={sortItems}
|
||||
defaultSelectedKey={
|
||||
searchParams.get("sort") ?? DEFAULT_SORT
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Sort by",
|
||||
})}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
onSelect={handleSortSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.divider}>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className={styles.filters}>
|
||||
<FilterContent
|
||||
filters={filters}
|
||||
activeFilters={selectedFilters}
|
||||
onChange={(id) => {
|
||||
const isSelected = selectedFilters.includes(id)
|
||||
setSelectedFilters((prev) =>
|
||||
isSelected
|
||||
? prev.filter((s) => s !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}}
|
||||
onFilteredCountChange={setFilteredCount}
|
||||
/>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="Large"
|
||||
onClick={() => handleApplyFiltersAndSorting(close)}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "See results ({ count })",
|
||||
},
|
||||
{
|
||||
count: filteredCount
|
||||
? filteredCount
|
||||
: unfilteredResultCount,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedFilters([])
|
||||
setFilteredCount(unfilteredResultCount)
|
||||
}}
|
||||
variant="Text"
|
||||
color="Primary"
|
||||
size="Medium"
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Clear all filters",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container[data-selected] .checkbox {
|
||||
border: var(--Surface-UI-Fill-Active);
|
||||
background: var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
|
||||
.container:focus-within .checkbox {
|
||||
outline: 2px solid var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||
border-radius: 4px;
|
||||
transition: all 200ms;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
forced-color-adjust: none;
|
||||
background: var(--UI-Input-Controls-Surface-Normal);
|
||||
}
|
||||
|
||||
.container[data-disabled] {
|
||||
color: var(--Text-Interactive-Disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.container[data-disabled] .checkbox {
|
||||
border-color: var(--Text-Interactive-Disabled);
|
||||
background: var(--Surface-Primary-Disabled);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as AriaCheckbox } from "react-aria-components"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./filterCheckbox.module.css"
|
||||
|
||||
import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox"
|
||||
|
||||
export default function FilterCheckbox({
|
||||
isSelected,
|
||||
name,
|
||||
id,
|
||||
isDisabled,
|
||||
onChange,
|
||||
}: FilterCheckboxProps) {
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.container}
|
||||
isSelected={isSelected}
|
||||
onChange={() => onChange(id)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<span className={styles.checkboxContainer}>
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected && (
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" />
|
||||
)}
|
||||
</span>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{name}</span>
|
||||
</Typography>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
.container {
|
||||
min-width: 272px;
|
||||
}
|
||||
|
||||
.container > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
padding-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.facilities:first-of-type {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.facilities ul {
|
||||
margin-top: var(--Space-x2);
|
||||
}
|
||||
.facilities:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(min-content, max-content));
|
||||
gap: var(--Space-x15);
|
||||
margin-bottom: var(--Space-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter:first-child {
|
||||
margin-top: var(--Space-x1);
|
||||
}
|
||||
|
||||
.filter input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import FilterCheckbox from "./FilterCheckbox"
|
||||
|
||||
import styles from "./filterContent.module.css"
|
||||
|
||||
import type {
|
||||
CategorizedHotelFilters,
|
||||
HotelFilter,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
interface FilterContentProps {
|
||||
filters: CategorizedHotelFilters
|
||||
activeFilters: string[]
|
||||
onChange: (id: string) => void
|
||||
onFilteredCountChange?: (count: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function FilterContent({
|
||||
filters,
|
||||
activeFilters,
|
||||
onChange,
|
||||
className,
|
||||
onFilteredCountChange = () => undefined,
|
||||
}: FilterContentProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const [filteredHotelIds, setFilteredHotelIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFilters.length) {
|
||||
const allFilters = [
|
||||
...filters.facilityFilters,
|
||||
...filters.surroundingsFilters,
|
||||
]
|
||||
setFilteredHotelIds(
|
||||
allFilters
|
||||
.filter((f) => activeFilters.includes(f.id.toString()))
|
||||
.map((f) => f.hotelIds)
|
||||
.reduce((accumulatedHotelIds, currentHotelIds) =>
|
||||
accumulatedHotelIds.filter((hotelId) =>
|
||||
currentHotelIds.includes(hotelId)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setFilteredHotelIds([])
|
||||
}
|
||||
}, [filters, activeFilters, setFilteredHotelIds])
|
||||
|
||||
useEffect(() => {
|
||||
onFilteredCountChange(filteredHotelIds.length)
|
||||
}, [filteredHotelIds, onFilteredCountChange])
|
||||
|
||||
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
function filterOutput(filters: HotelFilter[]) {
|
||||
return filters.map((filter) => {
|
||||
const isDisabled = filteredHotelIds.length
|
||||
? !filter.hotelIds.some((hotelId) => filteredHotelIds.includes(hotelId))
|
||||
: false
|
||||
|
||||
const combinedFiltersCount = filteredHotelIds.filter((id) =>
|
||||
filter.hotelIds.includes(id)
|
||||
).length
|
||||
|
||||
const filterCount = filter.hotelIds.length
|
||||
|
||||
return (
|
||||
<li key={filter.id} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={onChange}
|
||||
isSelected={activeFilters.some((f) => f === filter.id.toString())}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
{!isDisabled && (
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
<span>{`(${combinedFiltersCount > 0 ? combinedFiltersCount : filterCount})`}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${styles.container} ${className}`}>
|
||||
<div>
|
||||
<Title as="h4">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Filter by",
|
||||
})}
|
||||
</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Hotel facilities",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<ul>{filterOutput(filters.facilityFilters)}</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className={styles.facilities}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Hotel surroundings",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<ul>{filterOutput(filters.surroundingsFilters)}</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.container {
|
||||
min-width: 272px;
|
||||
}
|
||||
|
||||
.container form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.facilities:first-of-type {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.facilities ul {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
.facilities:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(min-content, max-content));
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter:first-child {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.filter input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
|
||||
|
||||
import FilterContent from "../FilterContent"
|
||||
|
||||
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
useInitializeFiltersFromUrl()
|
||||
|
||||
const { toggleFilter, activeFilters } = useHotelFilterStore((state) => ({
|
||||
toggleFilter: state.toggleFilter,
|
||||
activeFilters: state.activeFilters,
|
||||
}))
|
||||
|
||||
const trackFiltersEvent = useCallback(() => {
|
||||
const facilityMap = new Map(
|
||||
filters.facilityFilters.map((f) => [f.id.toString(), f.name])
|
||||
)
|
||||
const surroundingsMap = new Map(
|
||||
filters.surroundingsFilters.map((f) => [f.id.toString(), f.name])
|
||||
)
|
||||
|
||||
const hotelFacilitiesFilter = activeFilters
|
||||
.filter((id) => facilityMap.has(id))
|
||||
.map((id) => facilityMap.get(id))
|
||||
.join(",")
|
||||
|
||||
const hotelSurroundingsFilter = activeFilters
|
||||
.filter((id) => surroundingsMap.has(id))
|
||||
.map((id) => surroundingsMap.get(id))
|
||||
.join(",")
|
||||
|
||||
trackEvent({
|
||||
event: "filterUsed",
|
||||
filter: {
|
||||
filtersUsed: `Filters values - hotelfacilities:${hotelFacilitiesFilter}|hotelsurroundings:${hotelSurroundingsFilter}`,
|
||||
},
|
||||
})
|
||||
}, [activeFilters, filters.facilityFilters, filters.surroundingsFilters])
|
||||
|
||||
// Update the URL when the filters changes
|
||||
useEffect(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
const values = activeFilters.join(",")
|
||||
|
||||
if (values === "") {
|
||||
newSearchParams.delete("filters")
|
||||
} else {
|
||||
newSearchParams.set("filters", values)
|
||||
}
|
||||
|
||||
if (values !== searchParams.get("filters")) {
|
||||
if (values) {
|
||||
trackFiltersEvent()
|
||||
}
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
}
|
||||
}, [activeFilters, pathname, searchParams, trackFiltersEvent])
|
||||
|
||||
return (
|
||||
<FilterContent
|
||||
className={className}
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
onChange={toggleFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as FilterAndSortModal } from "./FilterAndSortModal"
|
||||
export { default as HotelFilter } from "./HotelFilter"
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
export default function HotelCount() {
|
||||
const intl = useIntl()
|
||||
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{amount, plural, one {# hotel} other {# hotels}}",
|
||||
},
|
||||
{ amount: resultCount }
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
|
||||
|
||||
import {
|
||||
type HotelSorterProps,
|
||||
type SortItem,
|
||||
SortOrder,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
export const DEFAULT_SORT = SortOrder.Distance
|
||||
|
||||
export default function HotelSorter({ discreet }: HotelSorterProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const intl = useIntl()
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string | number) => {
|
||||
const newSort = value.toString()
|
||||
if (newSort === searchParams.get("sort")) {
|
||||
return
|
||||
}
|
||||
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.set("sort", newSort)
|
||||
trackEvent({
|
||||
event: "sortOptionClick",
|
||||
filter: {
|
||||
sortOptions: newSort,
|
||||
},
|
||||
})
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
},
|
||||
[pathname, searchParams]
|
||||
)
|
||||
const sortItems: SortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Distance to city center",
|
||||
}),
|
||||
value: SortOrder.Distance,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Name",
|
||||
}),
|
||||
value: SortOrder.Name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Price",
|
||||
}),
|
||||
value: SortOrder.Price,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "TripAdvisor rating",
|
||||
}),
|
||||
value: SortOrder.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DeprecatedSelect
|
||||
items={sortItems}
|
||||
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Sort by",
|
||||
})}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Sort by",
|
||||
})}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
discreet={discreet}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
alternativeHotelsMap,
|
||||
selectHotelMap,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FilterAndSortModal from "../Filters/FilterAndSortModal"
|
||||
|
||||
import styles from "./mobileMapButtonContainer.module.css"
|
||||
|
||||
import type { CategorizedHotelFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function MobileMapButtonContainer({
|
||||
filters,
|
||||
isAlternative,
|
||||
}: {
|
||||
filters: CategorizedHotelFilters
|
||||
isAlternative?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
isAlternative ? alternativeHotelsMap(lang) : selectHotelMap(lang)
|
||||
}
|
||||
keepSearchParams
|
||||
weight="bold"
|
||||
>
|
||||
<MaterialIcon icon="map" color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See on map",
|
||||
})}
|
||||
</Link>
|
||||
</Button>
|
||||
<FilterAndSortModal filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.buttonContainer > * {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { NoAvailabilityAlertProp } from "@/types/components/hotelReservation/selectHotel/noAvailabilityAlert"
|
||||
|
||||
export default async function NoAvailabilityAlert({
|
||||
hotelsLength,
|
||||
bookingCode,
|
||||
isAllUnavailable,
|
||||
isAlternative,
|
||||
isBookingCodeRateNotAvailable,
|
||||
operaId,
|
||||
}: NoAvailabilityAlertProp) {
|
||||
const intl = await getIntl()
|
||||
const lang = await getLang()
|
||||
|
||||
if (bookingCode && isBookingCodeRateNotAvailable && hotelsLength > 0) {
|
||||
const bookingCodeText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||
},
|
||||
{ bookingCode }
|
||||
)
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={bookingCodeText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAllUnavailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hotelsLength === 1 && !isAlternative && operaId) {
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Please try and change your search for this destination or see alternative hotels.",
|
||||
})}
|
||||
link={{
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "See alternative hotels",
|
||||
}),
|
||||
url: `${alternativeHotels(lang)}?hotel=${operaId}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage: "There are no rooms available that match your request.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.hotelListingMobile {
|
||||
display: none;
|
||||
overflow-x: auto;
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hotelListingMobile[data-open="true"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hotelListing {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import HotelCardDialogListing from "@/components/HotelReservation/HotelCardDialogListing"
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelListing({
|
||||
hotels,
|
||||
unfilteredHotelCount,
|
||||
}: HotelListingProps) {
|
||||
const { activeHotel } = useHotelsMapStore()
|
||||
const isMobile = useMediaQuery("(max-width: 899px)")
|
||||
|
||||
return isMobile ? (
|
||||
<div className={styles.hotelListingMobile} data-open={!!activeHotel}>
|
||||
<HotelCardDialogListing
|
||||
hotels={hotels}
|
||||
unfilteredHotelCount={unfilteredHotelCount}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
unfilteredHotelCount={unfilteredHotelCount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
|
||||
|
||||
import { getHotelPins } from "../../HotelCardDialogListing/utils"
|
||||
import { getFiltersFromHotels, getHotels } from "../helpers"
|
||||
import { getSelectHotelTracking } from "../tracking"
|
||||
import SelectHotelMap from "."
|
||||
|
||||
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export async function SelectHotelMapContainer({
|
||||
booking,
|
||||
isAlternativeHotels,
|
||||
}: SelectHotelMapContainerProps) {
|
||||
const lang = await getLang()
|
||||
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
|
||||
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
|
||||
const getHotelSearchDetailsPromise = safeTry(
|
||||
getHotelSearchDetails(booking, isAlternativeHotels)
|
||||
)
|
||||
|
||||
const [searchDetails] = await getHotelSearchDetailsPromise
|
||||
|
||||
if (!searchDetails) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const {
|
||||
city,
|
||||
cityIdentifier,
|
||||
hotel: isAlternativeFor,
|
||||
redemption,
|
||||
} = searchDetails
|
||||
|
||||
if (!city) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const hotels = await getHotels({
|
||||
fromDate: booking.fromDate,
|
||||
toDate: booking.toDate,
|
||||
rooms: booking.rooms,
|
||||
isAlternativeFor,
|
||||
bookingCode: booking.bookingCode,
|
||||
city,
|
||||
redemption: !!redemption,
|
||||
})
|
||||
|
||||
const hotelPins = getHotelPins(hotels)
|
||||
const filterList = getFiltersFromHotels(hotels)
|
||||
const cityCoordinates = await getCityCoordinates({
|
||||
city: city.name,
|
||||
hotel: { address: hotels?.[0]?.hotel?.address.streetAddress },
|
||||
})
|
||||
|
||||
const arrivalDate = new Date(booking.fromDate)
|
||||
const departureDate = new Date(booking.toDate)
|
||||
const isRedemptionAvailability = redemption
|
||||
? hotels.some(
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
: false
|
||||
|
||||
const isBookingCodeRateAvailable = booking.bookingCode
|
||||
? hotels?.some((hotel) => hotel.availability.bookingCode)
|
||||
: false
|
||||
|
||||
const { hotelsTrackingData, pageTrackingData } = getSelectHotelTracking({
|
||||
lang,
|
||||
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
|
||||
pageName: isAlternativeHotels
|
||||
? "hotelreservation|alternative-hotels|mapview"
|
||||
: "hotelreservation|select-hotel|mapview",
|
||||
siteSections: isAlternativeHotels
|
||||
? "hotelreservation|altervative-hotels|mapview"
|
||||
: "hotelreservation|select-hotel|mapview",
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
rooms: booking.rooms,
|
||||
hotelsResult: hotels.length,
|
||||
searchTerm: isAlternativeFor ? booking.hotelId : cityIdentifier,
|
||||
country: hotels?.[0]?.hotel.address.country,
|
||||
hotelCity: hotels?.[0]?.hotel.address.city,
|
||||
bookingCode: booking.bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isRedemption: redemption,
|
||||
isRedemptionAvailable: isRedemptionAvailability,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
hotelPins={hotelPins}
|
||||
mapId={googleMapId}
|
||||
hotels={hotels}
|
||||
filterList={filterList}
|
||||
cityCoordinates={cityCoordinates}
|
||||
bookingCode={booking.bookingCode}
|
||||
isBookingCodeRateAvailable={isBookingCodeRateAvailable}
|
||||
isAlternativeHotels={isAlternativeHotels}
|
||||
/>
|
||||
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.listingContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-top: var(--Spacing-x6);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeletonItem {
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
max-width: 505px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.skeletonContainer {
|
||||
display: flex;
|
||||
width: 360px;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
|
||||
import styles from "./SelectHotelMapContainerSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SelectHotelMapContainerSkeleton({ count = 2 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer}>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className={styles.skeletonItem}>
|
||||
<RoomCardSkeleton />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mapContainer}>
|
||||
<SkeletonShimmer width={"100%"} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMap } from "@vis.gl/react-google-maps"
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import BookingCodeFilter from "@scandic-hotels/booking-flow/BookingCodeFilter"
|
||||
import {
|
||||
BookingCodeFilterEnum,
|
||||
useBookingCodeFilterStore,
|
||||
} from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import {
|
||||
alternativeHotels,
|
||||
selectHotel,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
|
||||
import HotelListing from "../HotelListing"
|
||||
import { getVisibleHotels } from "./utils"
|
||||
|
||||
import styles from "./selectHotelMapContent.module.css"
|
||||
|
||||
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
|
||||
const SKELETON_LOAD_DELAY = 750
|
||||
|
||||
export function SelectHotelMapContent({
|
||||
hotelPins,
|
||||
cityCoordinates,
|
||||
mapId,
|
||||
hotels,
|
||||
filterList,
|
||||
bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isAlternativeHotels,
|
||||
}: Omit<SelectHotelMapProps, "apiKey">) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const isUserLoggedIn = useIsUserLoggedIn()
|
||||
|
||||
const isAboveMobile = useMediaQuery("(min-width: 900px)")
|
||||
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
|
||||
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const hotelMapStore = useHotelsMapStore()
|
||||
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 490,
|
||||
elementRef: listingContainerRef,
|
||||
refScrollable: true,
|
||||
})
|
||||
const activeCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.activeCodeFilter
|
||||
)
|
||||
|
||||
const coordinates = useMemo(() => {
|
||||
if (hotelMapStore.activeHotel) {
|
||||
const hotel = hotels.find(
|
||||
(hotel) => hotel.hotel.name === hotelMapStore.activeHotel
|
||||
)
|
||||
|
||||
if (hotel && hotel.hotel.location) {
|
||||
return isAboveMobile
|
||||
? {
|
||||
lat: hotel.hotel.location.latitude,
|
||||
lng: hotel.hotel.location.longitude,
|
||||
}
|
||||
: {
|
||||
lat: hotel.hotel.location.latitude - 0.003,
|
||||
lng: hotel.hotel.location.longitude,
|
||||
}
|
||||
}
|
||||
}
|
||||
return isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
}, [hotelMapStore.activeHotel, hotels, isAboveMobile, cityCoordinates])
|
||||
|
||||
const showOnlyBookingCodeRates =
|
||||
bookingCode &&
|
||||
isBookingCodeRateAvailable &&
|
||||
activeCodeFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
const filteredHotelPins = useMemo(() => {
|
||||
const updatedHotelsList = showOnlyBookingCodeRates
|
||||
? hotelPins.filter((hotel) => hotel.bookingCode)
|
||||
: hotelPins
|
||||
return updatedHotelsList.filter((hotel) =>
|
||||
activeFilters.every((filterId) =>
|
||||
hotel.facilityIds.includes(Number(filterId))
|
||||
)
|
||||
)
|
||||
}, [activeFilters, hotelPins, showOnlyBookingCodeRates])
|
||||
|
||||
const getHotelCards = useCallback(() => {
|
||||
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
|
||||
setVisibleHotels(visibleHotels)
|
||||
setTimeout(() => {
|
||||
setShowSkeleton(false)
|
||||
}, SKELETON_LOAD_DELAY)
|
||||
}, [hotels, filteredHotelPins, map])
|
||||
|
||||
/**
|
||||
* Updates visible hotels when map viewport changes (zoom/pan)
|
||||
* - Debounces updates to prevent excessive re-renders during map interaction
|
||||
* - Shows loading skeleton while map tiles load
|
||||
* - Triggers on: initial load, zoom, pan, and tile loading completion
|
||||
*/
|
||||
const debouncedUpdateHotelCards = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!map) return
|
||||
if (isAboveMobile) {
|
||||
setShowSkeleton(true)
|
||||
}
|
||||
getHotelCards()
|
||||
}, 100),
|
||||
[map, getHotelCards, isAboveMobile]
|
||||
)
|
||||
|
||||
const closeMapUrl = isAlternativeHotels
|
||||
? alternativeHotels(lang)
|
||||
: selectHotel(lang)
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Inverted"
|
||||
wrapping
|
||||
size="Small"
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<Link
|
||||
href={closeMapUrl}
|
||||
keepSearchParams
|
||||
prefetch
|
||||
className={styles.link}
|
||||
>
|
||||
<MaterialIcon icon="close" size={20} color="CurrentColor" />
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Close the map",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const isSpecialRate = bookingCode
|
||||
? hotels.some(
|
||||
(hotel) =>
|
||||
hotel.availability.productType?.bonusCheque ||
|
||||
hotel.availability.productType?.voucher
|
||||
)
|
||||
: false
|
||||
|
||||
const showBookingCodeFilter =
|
||||
bookingCode && isBookingCodeRateAvailable && !isSpecialRate
|
||||
|
||||
const unfilteredHotelCount = hotelPins.length
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer} ref={listingContainerRef}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
variant="Text"
|
||||
type="button"
|
||||
size="Small"
|
||||
className={styles.filterContainerCloseButton}
|
||||
>
|
||||
<Link href={closeMapUrl} keepSearchParams className={styles.link}>
|
||||
<MaterialIcon
|
||||
icon="arrow_back_ios"
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>{intl.formatMessage({ defaultMessage: "Back" })}</p>
|
||||
</Typography>
|
||||
</Link>
|
||||
</Button>
|
||||
<FilterAndSortModal
|
||||
filters={filterList}
|
||||
setShowSkeleton={setShowSkeleton}
|
||||
/>
|
||||
{showBookingCodeFilter ? (
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<BookingCodeFilter />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
<div className={styles.skeletonContainer}>
|
||||
<RoomCardSkeleton />
|
||||
<RoomCardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<HotelListing
|
||||
hotels={visibleHotels}
|
||||
unfilteredHotelCount={unfilteredHotelCount}
|
||||
/>
|
||||
)}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton
|
||||
position="left"
|
||||
onClick={scrollToTop}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Back to top",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
hotelPins={filteredHotelPins.map((pin) => {
|
||||
const galleryImage = mapApiImagesToGalleryImages(pin.images).at(0)
|
||||
return {
|
||||
...pin,
|
||||
ratings: {
|
||||
tripAdvisor: pin.ratings ?? null,
|
||||
},
|
||||
image: {
|
||||
alt: galleryImage?.alt ?? "",
|
||||
url: galleryImage?.src ?? "",
|
||||
},
|
||||
}
|
||||
})}
|
||||
mapId={mapId}
|
||||
onTilesLoaded={debouncedUpdateHotelCards}
|
||||
fitBounds={isAboveMobile || !hotelMapStore.activeHotel}
|
||||
onHoverHotelPin={(args) => {
|
||||
if (!args) {
|
||||
hotelMapStore.disengageAfterDelay()
|
||||
return
|
||||
}
|
||||
|
||||
hotelMapStore.engage(args.hotelName)
|
||||
}}
|
||||
hoveredHotelPin={hotelMapStore.hoveredHotel}
|
||||
onSetActiveHotelPin={(args) => {
|
||||
if (!args || args.hotelName === hotelMapStore.activeHotel) {
|
||||
hotelMapStore.deactivate()
|
||||
return
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
event: "hotelClickMap",
|
||||
map: {
|
||||
action: "hotel click - map",
|
||||
},
|
||||
hotelInfo: {
|
||||
hotelId: args.hotelId,
|
||||
},
|
||||
})
|
||||
|
||||
hotelMapStore.activate(args.hotelName)
|
||||
}}
|
||||
onClickHotel={(hotelId) => {
|
||||
trackEvent({
|
||||
event: "hotelClickMap",
|
||||
map: {
|
||||
action: "hotel click - map",
|
||||
},
|
||||
hotelInfo: {
|
||||
hotelId,
|
||||
},
|
||||
})
|
||||
}}
|
||||
lang={lang}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
.container .closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Space-x05);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Space-x025) var(--Space-x2);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.container .listingContainer .filterContainer > button {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.bookingCodeFilter {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
}
|
||||
.container .listingContainer .filterContainer .filterContainerCloseButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Space-x3) var(--Space-x4) var(--Space-x3)
|
||||
var(--Layout-Tablet-Margin-Margin-min);
|
||||
overflow-y: auto;
|
||||
min-width: 420px;
|
||||
width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 var(--Space-x1);
|
||||
position: static;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.listingContainer {
|
||||
padding: var(--Space-x3) var(--Space-x4) var(--Space-x3)
|
||||
var(--Layout-Desktop-Margin-Margin-min);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
|
||||
export function getVisibleHotelPins(
|
||||
map: google.maps.Map | null,
|
||||
filteredHotelPins: HotelPin[]
|
||||
) {
|
||||
if (!map || !filteredHotelPins) return []
|
||||
|
||||
const bounds = map.getBounds()
|
||||
if (!bounds) return []
|
||||
|
||||
return filteredHotelPins.filter((pin) => {
|
||||
const { lat, lng } = pin.coordinates
|
||||
return bounds.contains({ lat, lng })
|
||||
})
|
||||
}
|
||||
|
||||
export function getVisibleHotels(
|
||||
hotels: HotelResponse[],
|
||||
filteredHotelPins: HotelPin[],
|
||||
map: google.maps.Map | null
|
||||
) {
|
||||
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
|
||||
const visibleHotels = hotels.filter((hotel) =>
|
||||
visibleHotelPins.some((pin) => pin.operaId === hotel.hotel.operaId)
|
||||
)
|
||||
return visibleHotels
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
|
||||
import { SelectHotelMapContent } from "./SelectHotelMapContent"
|
||||
|
||||
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function SelectHotelMap({
|
||||
apiKey,
|
||||
hotelPins,
|
||||
mapId,
|
||||
hotels,
|
||||
filterList,
|
||||
cityCoordinates,
|
||||
bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isAlternativeHotels,
|
||||
}: SelectHotelMapProps) {
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<SelectHotelMapContent
|
||||
hotelPins={hotelPins}
|
||||
cityCoordinates={cityCoordinates}
|
||||
mapId={mapId}
|
||||
hotels={hotels}
|
||||
filterList={filterList}
|
||||
bookingCode={bookingCode}
|
||||
isBookingCodeRateAvailable={isBookingCodeRateAvailable}
|
||||
isAlternativeHotels={isAlternativeHotels}
|
||||
/>
|
||||
</APIProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { HotelCardSkeleton } from "@scandic-hotels/design-system/HotelCard/HotelCardSkeleton"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import styles from "./selectHotel.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function SelectHotelSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.skeletonContainer}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.cityInformation}>
|
||||
<SkeletonShimmer height={"25px"} width={"200px"} />
|
||||
</div>
|
||||
<div className={styles.sorter}>
|
||||
<SkeletonShimmer height={"60px"} width={"100%"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.sideBar}>
|
||||
<div className={styles.sideBarItem}>
|
||||
<SkeletonShimmer height={"280px"} width={"340px"} />
|
||||
</div>
|
||||
<div className={styles.sideBarItem}>
|
||||
<SkeletonShimmer height={"400px"} width={"340px"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hotelList}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<HotelCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
import { generateChildrenString } from "@scandic-hotels/trpc/routers/hotels/helpers"
|
||||
|
||||
import { getHotel } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { HotelsAvailabilityItem } from "@scandic-hotels/trpc/types/availability"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
import type {
|
||||
AdditionalData,
|
||||
Hotel,
|
||||
Restaurant,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import type {
|
||||
HotelLocation,
|
||||
Location,
|
||||
} from "@scandic-hotels/trpc/types/locations"
|
||||
|
||||
import type {
|
||||
AlternativeHotelsAvailabilityInput,
|
||||
AvailabilityInput,
|
||||
} from "@/types/components/hotelReservation/selectHotel/availabilityInput"
|
||||
import type {
|
||||
CategorizedHotelFilters,
|
||||
HotelFilter,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
interface AvailabilityResponse {
|
||||
availability: HotelsAvailabilityItem[]
|
||||
}
|
||||
|
||||
export interface HotelResponse {
|
||||
availability: HotelsAvailabilityItem
|
||||
hotel: Hotel
|
||||
additionalData: AdditionalData
|
||||
url: string | null
|
||||
restaurants: Restaurant[]
|
||||
}
|
||||
|
||||
type Result = AvailabilityResponse | null
|
||||
type SettledResult = PromiseSettledResult<Result>[]
|
||||
|
||||
async function enhanceHotels(hotels: HotelsAvailabilityItem[]) {
|
||||
const language = await getLang()
|
||||
return await Promise.allSettled(
|
||||
hotels.map(async (availability) => {
|
||||
const hotelData = await getHotel({
|
||||
hotelId: availability.hotelId.toString(),
|
||||
isCardOnlyPayment: false,
|
||||
language,
|
||||
})
|
||||
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
availability,
|
||||
hotel: hotelData.hotel,
|
||||
additionalData: hotelData.additionalData,
|
||||
url: hotelData.url,
|
||||
restaurants: hotelData.restaurants,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchAlternativeHotels(
|
||||
hotelId: string,
|
||||
input: AlternativeHotelsAvailabilityInput
|
||||
) {
|
||||
const caller = await serverClient()
|
||||
const alternativeHotelIds = await caller.hotel.nearbyHotelIds({
|
||||
hotelId,
|
||||
})
|
||||
|
||||
if (!alternativeHotelIds) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await caller.hotel.availability.hotelsByHotelIds({
|
||||
...input,
|
||||
hotelIds: alternativeHotelIds,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchAvailableHotels(input: AvailabilityInput) {
|
||||
const caller = await serverClient()
|
||||
return await caller.hotel.availability.hotelsByCity(input)
|
||||
}
|
||||
|
||||
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
|
||||
const caller = await serverClient()
|
||||
return await caller.hotel.availability.hotelsByCityWithBookingCode(input)
|
||||
}
|
||||
|
||||
function getFulfilledResponses<T>(result: PromiseSettledResult<T | null>[]) {
|
||||
const fulfilledResponses: NonNullable<T>[] = []
|
||||
for (const res of result) {
|
||||
if (res.status === "fulfilled" && res.value) {
|
||||
fulfilledResponses.push(res.value)
|
||||
}
|
||||
}
|
||||
return fulfilledResponses
|
||||
}
|
||||
|
||||
function getHotelAvailabilityItems(hotels: AvailabilityResponse[]) {
|
||||
return hotels.map((hotel) => hotel.availability)
|
||||
}
|
||||
|
||||
// Filter out hotels that are unavailable for
|
||||
// at least one room.
|
||||
function sortAndFilterHotelsByAvailability(
|
||||
fulfilledHotels: HotelsAvailabilityItem[][]
|
||||
) {
|
||||
const availableHotels = new Map<
|
||||
HotelsAvailabilityItem["hotelId"],
|
||||
HotelsAvailabilityItem
|
||||
>()
|
||||
const unavailableHotels = new Map<
|
||||
HotelsAvailabilityItem["hotelId"],
|
||||
HotelsAvailabilityItem
|
||||
>()
|
||||
const unavailableHotelIds = new Set<HotelsAvailabilityItem["hotelId"]>()
|
||||
|
||||
for (const availabilityHotels of fulfilledHotels) {
|
||||
for (const hotel of availabilityHotels) {
|
||||
if (hotel.status === AvailabilityEnum.Available) {
|
||||
if (availableHotels.has(hotel.hotelId)) {
|
||||
const currentAddedHotel = availableHotels.get(hotel.hotelId)
|
||||
// Make sure the cheapest version of the room is the one
|
||||
// we keep so that it matches the cheapest room on select-rate
|
||||
if (
|
||||
(hotel.productType?.public &&
|
||||
currentAddedHotel?.productType?.public &&
|
||||
hotel.productType.public.localPrice.pricePerNight <
|
||||
currentAddedHotel.productType.public.localPrice
|
||||
.pricePerNight) ||
|
||||
(hotel.productType?.member &&
|
||||
currentAddedHotel?.productType?.member &&
|
||||
hotel.productType.member.localPrice.pricePerNight <
|
||||
currentAddedHotel.productType.member.localPrice.pricePerNight)
|
||||
) {
|
||||
availableHotels.set(hotel.hotelId, hotel)
|
||||
}
|
||||
} else {
|
||||
availableHotels.set(hotel.hotelId, hotel)
|
||||
}
|
||||
} else {
|
||||
unavailableHotels.set(hotel.hotelId, hotel)
|
||||
unavailableHotelIds.add(hotel.hotelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hotelId] of unavailableHotelIds.entries()) {
|
||||
if (availableHotels.has(hotelId)) {
|
||||
availableHotels.delete(hotelId)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
Array.from(availableHotels.values()),
|
||||
Array.from(unavailableHotels.values()),
|
||||
].flat()
|
||||
}
|
||||
|
||||
type GetHotelsInput = {
|
||||
fromDate: string
|
||||
toDate: string
|
||||
rooms: {
|
||||
adults: number
|
||||
childrenInRoom?: Child[]
|
||||
}[]
|
||||
isAlternativeFor: HotelLocation | null
|
||||
bookingCode: string | undefined
|
||||
city: Location
|
||||
redemption: boolean
|
||||
}
|
||||
|
||||
export async function getHotels({
|
||||
rooms,
|
||||
fromDate,
|
||||
toDate,
|
||||
isAlternativeFor,
|
||||
bookingCode,
|
||||
city,
|
||||
redemption,
|
||||
}: GetHotelsInput) {
|
||||
let availableHotelsResponse: SettledResult = []
|
||||
|
||||
// Return empty array (forced No availability) when search dates are invalid
|
||||
if (
|
||||
dt(fromDate).isBefore(dt(), "day") ||
|
||||
dt(toDate).isSameOrBefore(fromDate, "day")
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (isAlternativeFor) {
|
||||
availableHotelsResponse = await Promise.allSettled(
|
||||
rooms.map(async (room) => {
|
||||
return fetchAlternativeHotels(isAlternativeFor.id, {
|
||||
adults: room.adults,
|
||||
bookingCode,
|
||||
children: room.childrenInRoom
|
||||
? generateChildrenString(room.childrenInRoom)
|
||||
: undefined,
|
||||
redemption,
|
||||
roomStayEndDate: toDate,
|
||||
roomStayStartDate: fromDate,
|
||||
})
|
||||
})
|
||||
)
|
||||
} else if (bookingCode) {
|
||||
availableHotelsResponse = await Promise.allSettled(
|
||||
rooms.map(async (room) => {
|
||||
return fetchBookingCodeAvailableHotels({
|
||||
adults: room.adults,
|
||||
bookingCode,
|
||||
children: room.childrenInRoom
|
||||
? generateChildrenString(room.childrenInRoom)
|
||||
: undefined,
|
||||
cityId: city.id,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
})
|
||||
})
|
||||
)
|
||||
} else {
|
||||
availableHotelsResponse = await Promise.allSettled(
|
||||
rooms.map(
|
||||
async (room) =>
|
||||
await fetchAvailableHotels({
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom
|
||||
? generateChildrenString(room.childrenInRoom)
|
||||
: undefined,
|
||||
cityId: city.id,
|
||||
redemption,
|
||||
roomStayEndDate: toDate,
|
||||
roomStayStartDate: fromDate,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const fulfilledAvailabilities = getFulfilledResponses<AvailabilityResponse>(
|
||||
availableHotelsResponse
|
||||
)
|
||||
const availablilityItems = getHotelAvailabilityItems(fulfilledAvailabilities)
|
||||
const availableHotels = sortAndFilterHotelsByAvailability(availablilityItems)
|
||||
if (!availableHotels.length) {
|
||||
return []
|
||||
}
|
||||
const hotelsResponse = await enhanceHotels(availableHotels)
|
||||
const hotels = getFulfilledResponses<HotelResponse>(hotelsResponse)
|
||||
|
||||
return hotels
|
||||
}
|
||||
|
||||
const hotelSurroundingsFilterNames = [
|
||||
"Hotel surroundings",
|
||||
"Hotel omgivelser",
|
||||
"Hotelumgebung",
|
||||
"Hotellia lähellä",
|
||||
"Hotellomgivelser",
|
||||
"Omgivningar",
|
||||
]
|
||||
|
||||
const hotelFacilitiesFilterNames = [
|
||||
"Hotel facilities",
|
||||
"Hotellfaciliteter",
|
||||
"Hotelfaciliteter",
|
||||
"Hotel faciliteter",
|
||||
"Hotel-Infos",
|
||||
"Hotellin palvelut",
|
||||
]
|
||||
|
||||
export function getFiltersFromHotels(
|
||||
hotels: HotelResponse[]
|
||||
): CategorizedHotelFilters {
|
||||
const defaultFilters = { facilityFilters: [], surroundingsFilters: [] }
|
||||
if (!hotels.length) {
|
||||
return defaultFilters
|
||||
}
|
||||
|
||||
const filters = hotels.flatMap(({ hotel }) =>
|
||||
hotel.detailedFacilities.map(
|
||||
(facility) =>
|
||||
<HotelFilter>{
|
||||
...facility,
|
||||
hotelId: hotel.operaId,
|
||||
hotelIds: [hotel.operaId],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
|
||||
const filterList: HotelFilter[] = uniqueFilterIds
|
||||
.map((filterId) => {
|
||||
const filter = filters.find((f) => f.id === filterId)
|
||||
|
||||
// List and include all hotel Ids having same filter / amenity
|
||||
if (filter) {
|
||||
filter.hotelIds = filters
|
||||
.filter((f) => f.id === filterId)
|
||||
.map((f) => f.hotelId)
|
||||
}
|
||||
return filter
|
||||
})
|
||||
.filter((filter): filter is HotelFilter => filter !== undefined)
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
|
||||
return filterList.reduce<CategorizedHotelFilters>((filters, filter) => {
|
||||
if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter)) {
|
||||
filters.surroundingsFilters.push(filter)
|
||||
}
|
||||
|
||||
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter)) {
|
||||
filters.facilityFilters.push(filter)
|
||||
}
|
||||
|
||||
return filters
|
||||
}, defaultFilters)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import BookingCodeFilter from "@scandic-hotels/booking-flow/BookingCodeFilter"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
import HotelFilter from "@/components/HotelReservation/SelectHotel/Filters/HotelFilter"
|
||||
import HotelCount from "@/components/HotelReservation/SelectHotel/HotelCount"
|
||||
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
|
||||
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
|
||||
import NoAvailabilityAlert from "@/components/HotelReservation/SelectHotel/NoAvailabilityAlert"
|
||||
import { MapWithButtonWrapper } from "@/components/Maps/MapWithButtonWrapper"
|
||||
import StaticMap from "@/components/Maps/StaticMap"
|
||||
|
||||
import { getFiltersFromHotels, type HotelResponse } from "./helpers"
|
||||
|
||||
import styles from "./selectHotel.module.css"
|
||||
|
||||
import type { Location } from "@scandic-hotels/trpc/types/locations"
|
||||
|
||||
interface SelectHotelProps {
|
||||
isAlternative?: boolean
|
||||
bookingCode?: string
|
||||
city: Location
|
||||
hotels: HotelResponse[]
|
||||
isBookingCodeRateAvailable?: boolean
|
||||
mapHref: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export default async function SelectHotel({
|
||||
bookingCode,
|
||||
city,
|
||||
hotels,
|
||||
isAlternative = false,
|
||||
isBookingCodeRateAvailable = false,
|
||||
mapHref,
|
||||
title,
|
||||
}: SelectHotelProps) {
|
||||
const isAllUnavailable = hotels.every(
|
||||
(hotel) => hotel.availability.status !== "Available"
|
||||
)
|
||||
const isCityWithCountry = (city: any): city is { country: string } =>
|
||||
"country" in city
|
||||
|
||||
// Special rates (corporate cheque, voucher) will not have regular rate hotels availability
|
||||
const isSpecialRate = hotels.some(
|
||||
(hotel) =>
|
||||
hotel.availability.productType?.bonusCheque ||
|
||||
hotel.availability.productType?.voucher
|
||||
)
|
||||
|
||||
const filterList = getFiltersFromHotels(hotels)
|
||||
|
||||
const showBookingCodeFilter = isBookingCodeRateAvailable && !isSpecialRate
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.cityInformation}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p>{title}</p>
|
||||
</Typography>
|
||||
<HotelCount />
|
||||
</div>
|
||||
<div className={styles.sorter}>
|
||||
<HotelSorter discreet />
|
||||
</div>
|
||||
</div>
|
||||
<MobileMapButtonContainer filters={filterList} />
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
|
||||
<div className={styles.sideBar}>
|
||||
{hotels.length ? (
|
||||
<Link className={styles.link} href={mapHref} keepSearchParams>
|
||||
<MapWithButtonWrapper>
|
||||
<StaticMap
|
||||
city={city.name}
|
||||
country={isCityWithCountry(city) ? city.country : undefined}
|
||||
width={340}
|
||||
height={200}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${city.name} city center`}
|
||||
/>
|
||||
</MapWithButtonWrapper>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={city.name}
|
||||
width={340}
|
||||
height={200}
|
||||
zoomLevel={11}
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${city.name} city center`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<HotelFilter filters={filterList} className={styles.filter} />
|
||||
</div>
|
||||
<div className={styles.hotelList}>
|
||||
<NoAvailabilityAlert
|
||||
hotelsLength={hotels.length}
|
||||
isAlternative={isAlternative}
|
||||
isAllUnavailable={isAllUnavailable}
|
||||
operaId={hotels?.[0]?.hotel.operaId}
|
||||
bookingCode={bookingCode}
|
||||
isBookingCodeRateNotAvailable={!isBookingCodeRateAvailable}
|
||||
/>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
isAlternative={isAlternative}
|
||||
unfilteredHotelCount={hotels.length}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
.main {
|
||||
display: flex;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: min(100dvh, 750px);
|
||||
flex-direction: column;
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--Space-x3) 0 var(--Space-x2);
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.cityInformation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Space-x1);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sideBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sideBarItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hotelList {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeletonContainer .title {
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main {
|
||||
padding: var(--Space-x5) 0;
|
||||
flex-direction: row;
|
||||
gap: var(--Space-x5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Space-x4) 0 var(--Space-x3);
|
||||
}
|
||||
|
||||
.sorter {
|
||||
display: block;
|
||||
width: 339px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
max-width: var(--max-width-navigation);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sideBar {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.sideBarItem {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
margin-bottom: var(--Space-x6);
|
||||
}
|
||||
|
||||
.skeletonContainer .title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeletonContainer .sideBar {
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
import type { SelectHotelBooking } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
|
||||
|
||||
type SelectHotelTrackingInput = {
|
||||
lang: Lang
|
||||
pageId: string
|
||||
pageName: string
|
||||
siteSections: string
|
||||
arrivalDate: Date
|
||||
departureDate: Date
|
||||
rooms: SelectHotelBooking["rooms"]
|
||||
hotelsResult: number
|
||||
country: string | undefined
|
||||
hotelCity: string | undefined
|
||||
bookingCode?: string
|
||||
searchTerm?: string
|
||||
isBookingCodeRateAvailable?: boolean
|
||||
isRedemption?: boolean
|
||||
isRedemptionAvailable?: boolean
|
||||
}
|
||||
|
||||
export function getSelectHotelTracking({
|
||||
lang,
|
||||
pageId,
|
||||
pageName,
|
||||
siteSections,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
rooms,
|
||||
hotelsResult,
|
||||
country,
|
||||
hotelCity,
|
||||
searchTerm,
|
||||
bookingCode,
|
||||
isBookingCodeRateAvailable = false,
|
||||
isRedemption = false,
|
||||
isRedemptionAvailable = false,
|
||||
}: SelectHotelTrackingInput) {
|
||||
const pageTrackingData: TrackingSDKPageData = {
|
||||
channel: TrackingChannelEnum["hotelreservation"],
|
||||
domainLanguage: lang,
|
||||
pageId,
|
||||
pageName,
|
||||
pageType: "bookinghotelspage",
|
||||
siteSections,
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
let adultsInRoom: number[] = []
|
||||
let childrenInRoom: ChildrenInRoom = null
|
||||
if (rooms?.length) {
|
||||
adultsInRoom = rooms.map((room) => room.adults ?? 0)
|
||||
childrenInRoom = rooms.map((room) => room.childrenInRoom ?? null)
|
||||
}
|
||||
|
||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
ageOfChildren: childrenInRoom
|
||||
?.map((c) => c?.map((k) => k.age).join(",") ?? "")
|
||||
.join("|"),
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
availableResults: hotelsResult,
|
||||
bookingCode: bookingCode ?? "n/a",
|
||||
bookingCodeAvailability: bookingCode
|
||||
? isBookingCodeRateAvailable.toString()
|
||||
: undefined,
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
childBedPreference: childrenInRoom
|
||||
?.map((c) => c?.map((k) => ChildBedMapEnum[k.bed]).join(",") ?? "")
|
||||
.join("|"),
|
||||
country,
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
noOfAdults: adultsInRoom.join(","),
|
||||
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
|
||||
noOfRooms: rooms?.length ?? 0,
|
||||
region: hotelCity,
|
||||
rewardNight: isRedemption ? "yes" : "no",
|
||||
rewardNightAvailability: isRedemptionAvailable.toString(),
|
||||
searchTerm,
|
||||
searchType: "destination",
|
||||
}
|
||||
|
||||
return {
|
||||
hotelsTrackingData,
|
||||
pageTrackingData,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
@@ -10,7 +11,6 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
import { RoomCardSkeleton } from "@scandic-hotels/booking-flow/components/RoomCardSkeleton"
|
||||
|
||||
import styles from "./roomsListSkeleton.module.css"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
import { RoomCardSkeleton } from "@scandic-hotels/booking-flow/components/RoomCardSkeleton"
|
||||
|
||||
import styles from "./RoomsContainerSkeleton.module.css"
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
import type { Room } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@/types/components/tracking"
|
||||
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
import type { Room } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
type ChildrenInRoom = (Child[] | null)[] | null
|
||||
|
||||
type SelectRateTrackingInput = {
|
||||
lang: Lang
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
import FnFNotAllowedAlert from "@scandic-hotels/booking-flow/components/FnFNotAllowedAlert"
|
||||
import { HotelDetailsSidePeek } from "@scandic-hotels/booking-flow/components/HotelDetailsSidePeek"
|
||||
import { FamilyAndFriendsCodes } from "@scandic-hotels/common/constants/familyAndFriends"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { HotelInfoCard } from "@scandic-hotels/design-system/HotelInfoCard"
|
||||
|
||||
import { FamilyAndFriendsCodes } from "@/constants/booking"
|
||||
|
||||
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
|
||||
import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import FnFNotAllowedAlert from "../FnFNotAllowedAlert/FnFNotAllowedAlert"
|
||||
import { hasOverlappingDates } from "../utils"
|
||||
import AvailabilityError from "./AvailabilityError"
|
||||
import Tracking from "./Tracking"
|
||||
|
||||
@@ -8,10 +8,9 @@ import useLang from "@/hooks/useLang"
|
||||
import { useLazyPathname } from "@/hooks/useLazyPathname"
|
||||
import { trackLoginClick } from "@/utils/tracking"
|
||||
|
||||
import type { TrackingPosition } from "@scandic-hotels/common/tracking/types"
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
import type { TrackingPosition } from "@/types/components/tracking"
|
||||
|
||||
export default function LoginButton({
|
||||
position,
|
||||
trackingId,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||
|
||||
import styles from "./mapModal.module.css"
|
||||
|
||||
export function MapContainer({ children }: { children: React.ReactNode }) {
|
||||
const [mapHeight, setMapHeight] = useState("")
|
||||
const [mapTop, setMapTop] = useState("")
|
||||
const [mapZIndex, setMapZIndex] = useState(0)
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
|
||||
const handleMapHeight = useCallback(() => {
|
||||
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
|
||||
const scrollY = window.scrollY
|
||||
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
|
||||
setMapTop(`${topPosition + scrollY}px`)
|
||||
setMapZIndex(11)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const originalOverflowY = document.body.style.overflowY
|
||||
|
||||
// Function to enforce overflowY to hidden
|
||||
const enforceOverflowHidden = () => {
|
||||
if (document.body.style.overflowY !== "hidden") {
|
||||
document.body.style.overflowY = "hidden"
|
||||
}
|
||||
}
|
||||
|
||||
// Set overflowY to hidden initially
|
||||
enforceOverflowHidden()
|
||||
|
||||
// Create a MutationObserver to watch for changes to the style attribute
|
||||
const observer = new MutationObserver(() => {
|
||||
enforceOverflowHidden()
|
||||
})
|
||||
|
||||
// Observe changes to the style attribute of the body
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Disconnect the observer on cleanup
|
||||
observer.disconnect()
|
||||
// Restore the original overflowY style
|
||||
document.body.style.overflowY = originalOverflowY
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Making sure the map is always opened at the top of the page,
|
||||
// just below the header and booking widget as these should stay visible.
|
||||
// When closing, the page should scroll back to the position it was before opening the map.
|
||||
useEffect(() => {
|
||||
// Skip the first render
|
||||
if (!rootDiv.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollHeightWhenOpened === 0) {
|
||||
const scrollY = window.scrollY
|
||||
setScrollHeightWhenOpened(scrollY)
|
||||
window.scrollTo({ top: 0, behavior: "instant" })
|
||||
}
|
||||
}, [scrollHeightWhenOpened, rootDiv])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedResizeHandler = debounce(function () {
|
||||
handleMapHeight()
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(debouncedResizeHandler)
|
||||
|
||||
observer.observe(document.documentElement)
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.unobserve(document.documentElement)
|
||||
}
|
||||
}
|
||||
}, [rootDiv, handleMapHeight])
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--hotel-map-height": mapHeight,
|
||||
"--hotel-map-top": mapTop,
|
||||
"--hotel-dynamic-map-z-index": mapZIndex,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={styles.dynamicMap}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.dynamicMap {
|
||||
--hotel-map-height: 100dvh;
|
||||
--hotel-map-top: 145px;
|
||||
--hotel-dynamic-map-z-index: 2;
|
||||
position: fixed;
|
||||
top: var(--hotel-map-top);
|
||||
left: 0;
|
||||
height: var(--hotel-map-height);
|
||||
width: 100dvw;
|
||||
z-index: var(--hotel-dynamic-map-z-index);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { startTransition, useEffect } from "react"
|
||||
|
||||
import useRouterTransitionStore from "@/stores/router-transition"
|
||||
import useTrackingStore from "@/stores/tracking"
|
||||
import { isSameBookingWidgetParams } from "@scandic-hotels/booking-flow/utils/isSameBooking"
|
||||
import useRouterTransitionStore from "@scandic-hotels/common/stores/router-transition"
|
||||
import useTrackingStore from "@scandic-hotels/common/stores/tracking"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackPageViewStart } from "@/utils/tracking"
|
||||
@@ -34,7 +35,10 @@ export default function RouteChange() {
|
||||
}
|
||||
|
||||
updateRouteInfo(pathName, currentLang, searchParams)
|
||||
if (hasPathOrLangChanged() || hasBookingFlowParamsChanged()) {
|
||||
if (
|
||||
hasPathOrLangChanged() ||
|
||||
hasBookingFlowParamsChanged(isSameBookingWidgetParams)
|
||||
) {
|
||||
setInitialPageLoadTime(Date.now())
|
||||
trackPageViewStart()
|
||||
startTransition(() => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import AdditionalAmenities from "@scandic-hotels/booking-flow/components/AdditionalAmenities"
|
||||
import Contact from "@scandic-hotels/booking-flow/components/Contact"
|
||||
import BreakfastAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/BreakfastAccordionItem"
|
||||
import CheckInCheckOutAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/CheckInCheckOutAccordionItem"
|
||||
import ParkingAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/ParkingAccordionItem"
|
||||
import Accordion from "@scandic-hotels/design-system/Accordion"
|
||||
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
|
||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { trackAccordionClick } from "@/utils/tracking"
|
||||
|
||||
import styles from "./hotelSidePeek.module.css"
|
||||
|
||||
import type {
|
||||
AdditionalData,
|
||||
Hotel,
|
||||
Restaurant,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface HotelSidePeekContentProps {
|
||||
hotel: Hotel & { url: string | null }
|
||||
restaurants: Restaurant[]
|
||||
additionalHotelData: AdditionalData | undefined
|
||||
}
|
||||
|
||||
export function HotelSidePeekContent({
|
||||
hotel,
|
||||
restaurants,
|
||||
additionalHotelData,
|
||||
}: HotelSidePeekContentProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h3>
|
||||
{intl.formatMessage({ defaultMessage: "Practical information" })}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Contact hotel={hotel} />
|
||||
|
||||
<Accordion>
|
||||
<ParkingAccordionItem
|
||||
parking={hotel.parking}
|
||||
elevatorPitch={additionalHotelData?.hotelParking.elevatorPitch}
|
||||
/>
|
||||
<BreakfastAccordionItem
|
||||
restaurants={restaurants}
|
||||
hotelType={hotel.hotelType}
|
||||
/>
|
||||
<CheckInCheckOutAccordionItem checkInData={hotel.hotelFacts.checkin} />
|
||||
<AccessibilityAccordionItem
|
||||
elevatorPitch={additionalHotelData?.hotelSpecialNeeds.elevatorPitch}
|
||||
/>
|
||||
<AdditionalAmenities amenities={hotel.detailedFacilities} />
|
||||
</Accordion>
|
||||
{hotel.url ? (
|
||||
<ButtonLink
|
||||
href={hotel.url}
|
||||
variant="Secondary"
|
||||
size="Medium"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Read more about the hotel",
|
||||
})}
|
||||
</ButtonLink>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AccessibilityAccordionItemProps = {
|
||||
elevatorPitch?: string
|
||||
}
|
||||
|
||||
function AccessibilityAccordionItem({
|
||||
elevatorPitch,
|
||||
}: AccessibilityAccordionItemProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!elevatorPitch) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Accessibility",
|
||||
})}
|
||||
iconName={IconName.Accessibility}
|
||||
className={styles.accordionItem}
|
||||
variant="sidepeek"
|
||||
onOpen={() => trackAccordionClick("amenities:accessibility")}
|
||||
>
|
||||
<div className={styles.accessibilityContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{elevatorPitch}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { DialogTrigger } from "react-aria-components"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
|
||||
|
||||
import { trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||
|
||||
import { HotelSidePeekContent } from "./HotelSidePeekContent"
|
||||
|
||||
import type {
|
||||
AdditionalData,
|
||||
Hotel,
|
||||
Restaurant,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import { SidePeekEnum } from "@/types/sidepeek"
|
||||
|
||||
interface HotelDetailsSidePeekProps {
|
||||
hotel: Hotel & { url: string | null }
|
||||
restaurants: Restaurant[]
|
||||
additionalHotelData: AdditionalData | undefined
|
||||
triggerLabel: string
|
||||
buttonVariant: "primary" | "secondary"
|
||||
wrapping?: boolean
|
||||
}
|
||||
|
||||
const buttonPropsMap: Record<
|
||||
HotelDetailsSidePeekProps["buttonVariant"],
|
||||
Pick<
|
||||
React.ComponentProps<typeof Button>,
|
||||
"variant" | "color" | "size" | "typography"
|
||||
>
|
||||
> = {
|
||||
primary: {
|
||||
variant: "Text",
|
||||
color: "Primary",
|
||||
size: "Medium",
|
||||
typography: "Body/Paragraph/mdBold",
|
||||
},
|
||||
secondary: {
|
||||
variant: "Secondary",
|
||||
color: "Inverted",
|
||||
size: "Small",
|
||||
typography: "Body/Supporting text (caption)/smBold",
|
||||
},
|
||||
}
|
||||
|
||||
export default function HotelDetailsSidePeek({
|
||||
hotel,
|
||||
restaurants,
|
||||
additionalHotelData,
|
||||
triggerLabel,
|
||||
wrapping = true,
|
||||
buttonVariant,
|
||||
}: HotelDetailsSidePeekProps) {
|
||||
const buttonProps = buttonPropsMap[buttonVariant]
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
wrapping={wrapping}
|
||||
onPress={() =>
|
||||
trackOpenSidePeekEvent({
|
||||
name: SidePeekEnum.hotelDetails,
|
||||
hotelId: hotel.operaId,
|
||||
includePathname: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{triggerLabel}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={buttonVariant === "primary" ? 24 : 20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<SidePeekSelfControlled title={hotel.name}>
|
||||
<HotelSidePeekContent
|
||||
hotel={hotel}
|
||||
restaurants={restaurants}
|
||||
additionalHotelData={additionalHotelData}
|
||||
/>
|
||||
</SidePeekSelfControlled>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import {
|
||||
type Control,
|
||||
type FieldValues,
|
||||
useFormState,
|
||||
type UseFromSubscribe,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { useSessionId } from "@scandic-hotels/common/hooks/useSessionId"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import useRouterTransitionStore from "@/stores/router-transition"
|
||||
import useTrackingStore from "@/stores/tracking"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { promiseWithTimeout } from "@/utils/promiseWithTimeout"
|
||||
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
|
||||
import {
|
||||
type FormType,
|
||||
trackFormAbandonment,
|
||||
trackFormCompletion,
|
||||
trackFormInputStarted,
|
||||
} from "@/utils/tracking/form"
|
||||
|
||||
import type {
|
||||
TrackingSDKProps,
|
||||
TrackingSDKUserData,
|
||||
} from "@/types/components/tracking"
|
||||
|
||||
enum TransitionStatusEnum {
|
||||
NotRun = "NotRun",
|
||||
Running = "Running",
|
||||
Done = "Done",
|
||||
}
|
||||
|
||||
type TransitionStatus = keyof typeof TransitionStatusEnum
|
||||
|
||||
let hasTrackedHardNavigation = false
|
||||
export const useTrackHardNavigation = ({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
}: TrackingSDKProps) => {
|
||||
const lang = useLang()
|
||||
const {
|
||||
data: userTrackingData,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.user.userTrackingInfo.useQuery({ lang })
|
||||
|
||||
const sessionId = useSessionId()
|
||||
const pathName = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const userData: TrackingSDKUserData = isError
|
||||
? { loginStatus: "Error" }
|
||||
: userTrackingData
|
||||
|
||||
if (hasTrackedHardNavigation) {
|
||||
return
|
||||
}
|
||||
|
||||
hasTrackedHardNavigation = true
|
||||
|
||||
const track = () => {
|
||||
trackPerformance({
|
||||
pathName,
|
||||
sessionId,
|
||||
paymentInfo,
|
||||
hotelInfo,
|
||||
userData,
|
||||
pageData,
|
||||
ancillaries,
|
||||
})
|
||||
}
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
track()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener("load", track)
|
||||
return () => window.removeEventListener("load", track)
|
||||
}, [
|
||||
isError,
|
||||
pathName,
|
||||
hotelInfo,
|
||||
userTrackingData,
|
||||
pageData,
|
||||
sessionId,
|
||||
paymentInfo,
|
||||
isPending,
|
||||
ancillaries,
|
||||
])
|
||||
}
|
||||
|
||||
export const useTrackSoftNavigation = ({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
}: TrackingSDKProps) => {
|
||||
const lang = useLang()
|
||||
const {
|
||||
data: userTrackingData,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.user.userTrackingInfo.useQuery({ lang })
|
||||
|
||||
const [status, setStatus] = useState<TransitionStatus>(
|
||||
TransitionStatusEnum.NotRun
|
||||
)
|
||||
const { getPageLoadTime } = useTrackingStore()
|
||||
|
||||
const sessionId = useSessionId()
|
||||
const pathName = usePathname()
|
||||
const { isTransitioning, stopRouterTransition } = useRouterTransitionStore()
|
||||
|
||||
const previousPathname = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTransitioning && status === TransitionStatusEnum.NotRun) {
|
||||
startTransition(() => {
|
||||
setStatus(TransitionStatusEnum.Running)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isTransitioning && status === TransitionStatusEnum.Running) {
|
||||
setStatus(TransitionStatusEnum.Done)
|
||||
stopRouterTransition()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTransitioning && status === TransitionStatusEnum.Done) {
|
||||
const pageLoadTime = getPageLoadTime()
|
||||
const trackingData = {
|
||||
...pageData,
|
||||
sessionId,
|
||||
pathName,
|
||||
pageLoadTime: pageLoadTime,
|
||||
}
|
||||
const pageObject = createSDKPageObject(trackingData)
|
||||
const userData: TrackingSDKUserData = isError
|
||||
? { loginStatus: "Error" }
|
||||
: userTrackingData
|
||||
|
||||
trackPageView({
|
||||
event: "pageView",
|
||||
pageInfo: pageObject,
|
||||
userInfo: userData,
|
||||
hotelInfo: hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
})
|
||||
|
||||
setStatus(TransitionStatusEnum.NotRun) // Reset status
|
||||
previousPathname.current = pathName // Update for next render
|
||||
}
|
||||
}, [
|
||||
isError,
|
||||
isPending,
|
||||
isTransitioning,
|
||||
status,
|
||||
stopRouterTransition,
|
||||
pageData,
|
||||
pathName,
|
||||
hotelInfo,
|
||||
getPageLoadTime,
|
||||
sessionId,
|
||||
paymentInfo,
|
||||
userTrackingData,
|
||||
ancillaries,
|
||||
])
|
||||
}
|
||||
|
||||
const trackPerformance = async ({
|
||||
pathName,
|
||||
sessionId,
|
||||
paymentInfo,
|
||||
hotelInfo,
|
||||
userData,
|
||||
pageData,
|
||||
ancillaries,
|
||||
}: {
|
||||
pathName: string
|
||||
sessionId: string | null
|
||||
paymentInfo: TrackingSDKProps["paymentInfo"]
|
||||
hotelInfo: TrackingSDKProps["hotelInfo"]
|
||||
userData: TrackingSDKUserData
|
||||
pageData: TrackingSDKProps["pageData"]
|
||||
ancillaries: TrackingSDKProps["ancillaries"]
|
||||
}) => {
|
||||
let pageLoadTime: number | undefined = undefined
|
||||
let lcpTime: number | undefined = undefined
|
||||
|
||||
try {
|
||||
pageLoadTime = await promiseWithTimeout(getPageLoadTimeEntry(), 3000)
|
||||
} catch (error) {
|
||||
logger.error("Error obtaining pageLoadTime:", error)
|
||||
}
|
||||
|
||||
try {
|
||||
lcpTime = await promiseWithTimeout(getLCPTimeEntry(), 3000)
|
||||
} catch (error) {
|
||||
logger.error("Error obtaining lcpTime:", error)
|
||||
}
|
||||
|
||||
const trackingData = {
|
||||
...pageData,
|
||||
sessionId,
|
||||
pathName,
|
||||
pageLoadTime,
|
||||
lcpTime,
|
||||
}
|
||||
const pageObject = createSDKPageObject(trackingData)
|
||||
|
||||
trackPageView({
|
||||
event: "pageView",
|
||||
pageInfo: pageObject,
|
||||
userInfo: userData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
})
|
||||
}
|
||||
|
||||
const getLCPTimeEntry = () => {
|
||||
return new Promise<number | undefined>((resolve) => {
|
||||
const observer = new PerformanceObserver((entries) => {
|
||||
const lastEntry = entries.getEntries().at(-1)
|
||||
if (lastEntry) {
|
||||
observer.disconnect()
|
||||
resolve(lastEntry.startTime / 1000)
|
||||
}
|
||||
})
|
||||
|
||||
const lcpSupported = PerformanceObserver.supportedEntryTypes?.includes(
|
||||
"largest-contentful-paint"
|
||||
)
|
||||
|
||||
if (lcpSupported) {
|
||||
observer.observe({
|
||||
type: "largest-contentful-paint",
|
||||
buffered: true,
|
||||
})
|
||||
} else {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getPageLoadTimeEntry = () => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const observer = new PerformanceObserver((entries) => {
|
||||
const navEntry = entries.getEntriesByType("navigation")[0]
|
||||
if (navEntry) {
|
||||
observer.disconnect()
|
||||
resolve(navEntry.duration / 1000)
|
||||
}
|
||||
})
|
||||
observer.observe({ type: "navigation", buffered: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function useFormTracking<T extends FieldValues>(
|
||||
formType: FormType,
|
||||
subscribe: UseFromSubscribe<T>,
|
||||
control: Control<T>,
|
||||
nameSuffix: string = ""
|
||||
) {
|
||||
const [formStarted, setFormStarted] = useState(false)
|
||||
const lastAccessedField = useRef<string | undefined>(undefined)
|
||||
const formState = useFormState({ control })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe({
|
||||
formState: { dirtyFields: true },
|
||||
callback: (data) => {
|
||||
if ("name" in data) {
|
||||
lastAccessedField.current = data.name as string
|
||||
}
|
||||
|
||||
if (!formStarted) {
|
||||
trackFormInputStarted(formType, nameSuffix)
|
||||
setFormStarted(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [subscribe, formType, nameSuffix, formStarted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formStarted || !lastAccessedField.current || formState.isValid) return
|
||||
|
||||
const lastField = lastAccessedField.current
|
||||
|
||||
function handleBeforeUnload() {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
window.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
window.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [formStarted, formType, nameSuffix, formState.isValid])
|
||||
|
||||
const trackFormSubmit = useCallback(() => {
|
||||
if (formState.isValid) {
|
||||
trackFormCompletion(formType, nameSuffix)
|
||||
}
|
||||
}, [formType, nameSuffix, formState.isValid])
|
||||
|
||||
return {
|
||||
trackFormSubmit,
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useTrackHardNavigation,
|
||||
useTrackSoftNavigation,
|
||||
} from "@/components/TrackingSDK/hooks"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { useTrackHardNavigation } from "@scandic-hotels/common/tracking/useTrackHardNavigation"
|
||||
import { useTrackSoftNavigation } from "@scandic-hotels/common/tracking/useTrackSoftNavigation"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import type {
|
||||
TrackingSDKAncillaries,
|
||||
TrackingSDKHotelInfo,
|
||||
TrackingSDKPageData,
|
||||
TrackingSDKPaymentInfo,
|
||||
} from "@/types/components/tracking"
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
export default function TrackingSDK({
|
||||
pageData,
|
||||
@@ -23,8 +26,30 @@ export default function TrackingSDK({
|
||||
paymentInfo?: TrackingSDKPaymentInfo
|
||||
ancillaries?: TrackingSDKAncillaries
|
||||
}) {
|
||||
useTrackHardNavigation({ pageData, hotelInfo, paymentInfo, ancillaries })
|
||||
useTrackSoftNavigation({ pageData, hotelInfo, paymentInfo, ancillaries })
|
||||
const lang = useLang()
|
||||
const pathName = usePathname()
|
||||
const { data, isError } = trpc.user.userTrackingInfo.useQuery({
|
||||
lang,
|
||||
})
|
||||
|
||||
const userData = isError ? ({ loginStatus: "Error" } as const) : data
|
||||
|
||||
useTrackHardNavigation({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
userData,
|
||||
pathName,
|
||||
})
|
||||
useTrackSoftNavigation({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
userData,
|
||||
pathName,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
78
apps/scandic-web/components/TrackingSDK/useFormTracking.ts
Normal file
78
apps/scandic-web/components/TrackingSDK/useFormTracking.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
type Control,
|
||||
type FieldValues,
|
||||
useFormState,
|
||||
type UseFromSubscribe,
|
||||
} from "react-hook-form"
|
||||
|
||||
import {
|
||||
type FormType,
|
||||
trackFormAbandonment,
|
||||
trackFormCompletion,
|
||||
trackFormInputStarted,
|
||||
} from "@/utils/tracking/form"
|
||||
|
||||
export function useFormTracking<T extends FieldValues>(
|
||||
formType: FormType,
|
||||
subscribe: UseFromSubscribe<T>,
|
||||
control: Control<T>,
|
||||
nameSuffix: string = ""
|
||||
) {
|
||||
const [formStarted, setFormStarted] = useState(false)
|
||||
const lastAccessedField = useRef<string | undefined>(undefined)
|
||||
const formState = useFormState({ control })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe({
|
||||
formState: { dirtyFields: true },
|
||||
callback: (data) => {
|
||||
if ("name" in data) {
|
||||
lastAccessedField.current = data.name as string
|
||||
}
|
||||
|
||||
if (!formStarted) {
|
||||
trackFormInputStarted(formType, nameSuffix)
|
||||
setFormStarted(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [subscribe, formType, nameSuffix, formStarted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formStarted || !lastAccessedField.current || formState.isValid) return
|
||||
|
||||
const lastField = lastAccessedField.current
|
||||
|
||||
function handleBeforeUnload() {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
window.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
window.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [formStarted, formType, nameSuffix, formState.isValid])
|
||||
|
||||
const trackFormSubmit = useCallback(() => {
|
||||
if (formState.isValid) {
|
||||
trackFormCompletion(formType, nameSuffix)
|
||||
}
|
||||
}, [formType, nameSuffix, formState.isValid])
|
||||
|
||||
return {
|
||||
trackFormSubmit,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
|
||||
export const FamilyAndFriendsCodes = ["D000029555", "D000029271", "D000029195"]
|
||||
|
||||
export const SEARCHTYPE = "searchtype"
|
||||
|
||||
export const MEMBERSHIP_FAILED_ERROR = "MembershipFailedError"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
|
||||
import { isRateSelected } from "./isRateSelected"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
|
||||
import type { Rate } from "./types"
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
export default function useInitializeFiltersFromUrl() {
|
||||
const searchParams = useSearchParams()
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
|
||||
useEffect(() => {
|
||||
const filtersFromUrl = searchParams.get("filters")
|
||||
if (filtersFromUrl) {
|
||||
setFilters(filtersFromUrl.split(","))
|
||||
} else {
|
||||
setFilters([])
|
||||
}
|
||||
}, [searchParams, setFilters])
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { isDefined } from "@scandic-hotels/common/utils/isDefined"
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { cache } from "@/utils/cache"
|
||||
|
||||
import { serverClient } from "../server"
|
||||
@@ -12,22 +9,13 @@ import type { GetHotelsByCSFilterInput } from "@scandic-hotels/trpc/routers/hote
|
||||
import type { GetSavedPaymentCardsInput } from "@scandic-hotels/trpc/routers/user/input"
|
||||
import type { RoomsAvailabilityExtendedInputSchema } from "@scandic-hotels/trpc/types/availability"
|
||||
import type { Country } from "@scandic-hotels/trpc/types/country"
|
||||
import type {
|
||||
CityCoordinatesInput,
|
||||
HotelInput,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelInput } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type {
|
||||
AncillaryPackagesInput,
|
||||
BreackfastPackagesInput,
|
||||
PackagesInput,
|
||||
} from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export const getLocations = cache(async function getMemoizedLocations() {
|
||||
const lang = await getLang()
|
||||
const caller = await serverClient()
|
||||
return caller.hotel.locations.get({ lang })
|
||||
})
|
||||
|
||||
export const getProfile = cache(async function getMemoizedProfile() {
|
||||
const caller = await serverClient()
|
||||
return caller.user.get()
|
||||
@@ -187,13 +175,6 @@ export const getLinkedReservations = cache(
|
||||
}
|
||||
)
|
||||
|
||||
export const getCityCoordinates = cache(
|
||||
async function getMemoizedCityCoordinates(input: CityCoordinatesInput) {
|
||||
const caller = await serverClient()
|
||||
return caller.hotel.map.city(input)
|
||||
}
|
||||
)
|
||||
|
||||
export const getCurrentRewards = cache(
|
||||
async function getMemoizedCurrentRewards() {
|
||||
const caller = await serverClient()
|
||||
@@ -294,83 +275,6 @@ export const getJobylonFeed = cache(async function getMemoizedJobylonFeed() {
|
||||
return caller.partner.jobylon.feed.get()
|
||||
})
|
||||
|
||||
export const getJumpToData = cache(async function getMemoizedJumpToData() {
|
||||
const lang = await getLang()
|
||||
const caller = await serverClient()
|
||||
const [locationsResults, urlsResults] = await Promise.allSettled([
|
||||
getLocations(),
|
||||
caller.hotel.locations.urls({ lang }),
|
||||
])
|
||||
|
||||
if (
|
||||
locationsResults.status === "fulfilled" &&
|
||||
urlsResults.status === "fulfilled"
|
||||
) {
|
||||
const locations = locationsResults.value
|
||||
const urls = urlsResults.value
|
||||
|
||||
if (!locations || !urls) {
|
||||
return null
|
||||
}
|
||||
|
||||
return locations
|
||||
.map((location) => {
|
||||
const { id, name, type } = location
|
||||
|
||||
const isCity = type === "cities"
|
||||
const isHotel = type === "hotels"
|
||||
|
||||
let url: string | undefined
|
||||
|
||||
if (isCity) {
|
||||
url = urls.cities.find(
|
||||
(c) =>
|
||||
c.city &&
|
||||
location.cityIdentifier &&
|
||||
c.city === location.cityIdentifier
|
||||
)?.url
|
||||
} else if (isHotel) {
|
||||
url = urls.hotels.find(
|
||||
(h) => h.hotelId && location.id && h.hotelId === location.id
|
||||
)?.url
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
let description = ""
|
||||
if (isCity) {
|
||||
description = location.country
|
||||
} else if (isHotel) {
|
||||
description = location.relationships.city.name
|
||||
}
|
||||
|
||||
const rankingNames: string[] = [location.name]
|
||||
if (isCity) {
|
||||
if (location.cityIdentifier) {
|
||||
rankingNames.push(location.cityIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
const rankingKeywords = location.keyWords || []
|
||||
|
||||
return {
|
||||
id,
|
||||
displayName: name,
|
||||
type,
|
||||
description,
|
||||
url,
|
||||
rankingNames: rankingNames.map((v) => v.toLowerCase()),
|
||||
rankingKeywords: rankingKeywords.map((v) => v.toLowerCase()),
|
||||
}
|
||||
})
|
||||
.filter(isDefined)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export const getSelectedRoomsAvailabilityEnterDetails = cache(
|
||||
async function getMemoizedSelectedRoomsAvailability(
|
||||
input: RoomsAvailabilityExtendedInputSchema
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import deepmerge from "deepmerge"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { isSameBooking } from "@scandic-hotels/booking-flow/utils/isSameBooking"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
|
||||
import { createDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
checkIsSameBooking as checkIsSameBooking,
|
||||
clearSessionStorage,
|
||||
getTotalPrice,
|
||||
readFromSessionStorage,
|
||||
@@ -89,8 +89,8 @@ export default function EnterDetailsProvider({
|
||||
setHasInitializedStore(true)
|
||||
return
|
||||
}
|
||||
const isSameBooking = checkIsSameBooking(storedValues.booking, booking)
|
||||
if (!isSameBooking) {
|
||||
|
||||
if (!isSameBooking(storedValues.booking, booking)) {
|
||||
clearSessionStorage()
|
||||
setHasInitializedStore(true)
|
||||
return
|
||||
|
||||
@@ -48,33 +48,6 @@ export function extractGuestFromUser(user: NonNullable<SafeUser>) {
|
||||
}
|
||||
}
|
||||
|
||||
export function checkIsSameBooking(
|
||||
prev: (SelectRateBooking | BookingWidgetSearchData) & { errorCode?: string },
|
||||
next: (SelectRateBooking | BookingWidgetSearchData) & { errorCode?: string }
|
||||
) {
|
||||
const { rooms: prevRooms, errorCode: prevErrorCode, ...prevBooking } = prev
|
||||
|
||||
const prevRoomsWithoutRateCodes = prevRooms?.map(
|
||||
({ adults, childrenInRoom }) => ({ adults, childrenInRoom })
|
||||
)
|
||||
const { rooms: nextRooms, errorCode: nextErrorCode, ...nextBooking } = next
|
||||
|
||||
const nextRoomsWithoutRateCodes = nextRooms?.map(
|
||||
({ adults, childrenInRoom }) => ({ adults, childrenInRoom })
|
||||
)
|
||||
|
||||
return isEqual(
|
||||
{
|
||||
...prevBooking,
|
||||
rooms: prevRoomsWithoutRateCodes,
|
||||
},
|
||||
{
|
||||
...nextBooking,
|
||||
rooms: nextRoomsWithoutRateCodes,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function add(...nums: (number | string | undefined)[]) {
|
||||
return nums.reduce((total: number, num) => {
|
||||
if (typeof num === "undefined") {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface HotelFilterState {
|
||||
activeFilters: string[]
|
||||
toggleFilter: (filterId: string) => void
|
||||
setFilters: (filters: string[]) => void
|
||||
resultCount: number
|
||||
unfilteredResultCount: number
|
||||
setResultCount: (count: number, unfilteredCount: number) => void
|
||||
}
|
||||
|
||||
export const useHotelFilterStore = create<HotelFilterState>((set) => ({
|
||||
activeFilters: [],
|
||||
|
||||
setFilters: (filters) => set({ activeFilters: filters }),
|
||||
|
||||
toggleFilter: (filterId: string) =>
|
||||
set((state) => {
|
||||
const isActive = state.activeFilters.includes(filterId)
|
||||
const newFilters = isActive
|
||||
? state.activeFilters.filter((id) => id !== filterId)
|
||||
: [...state.activeFilters, filterId]
|
||||
return { activeFilters: newFilters }
|
||||
}),
|
||||
resultCount: 0,
|
||||
unfilteredResultCount: 0,
|
||||
setResultCount: (count, unfilteredCount) =>
|
||||
set({ resultCount: count, unfilteredResultCount: unfilteredCount }),
|
||||
}))
|
||||
@@ -1,43 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface HotelsMapState {
|
||||
activeHotel: string | null
|
||||
hoveredHotel: string | null
|
||||
hoverTimeout: number | null
|
||||
activate: (hotel: string | null) => void
|
||||
deactivate: () => void
|
||||
engage: (hotel: string | null) => void
|
||||
disengage: () => void
|
||||
disengageAfterDelay: () => void
|
||||
}
|
||||
|
||||
export const useHotelsMapStore = create<HotelsMapState>((set, get) => ({
|
||||
activeHotel: null,
|
||||
hoveredHotel: null,
|
||||
hoverTimeout: null,
|
||||
activate: (hotel) => set({ activeHotel: hotel }),
|
||||
deactivate: () => set({ activeHotel: null }),
|
||||
engage: (hotel) => {
|
||||
const state = get()
|
||||
|
||||
if (state.hoverTimeout) {
|
||||
window.clearTimeout(state.hoverTimeout)
|
||||
}
|
||||
|
||||
if (hotel && state.activeHotel) {
|
||||
set({ activeHotel: null })
|
||||
}
|
||||
|
||||
set({ hoveredHotel: hotel })
|
||||
},
|
||||
disengage: () => {
|
||||
set({ hoveredHotel: null })
|
||||
},
|
||||
disengageAfterDelay: () => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
set({ hoveredHotel: null, activeHotel: null, hoverTimeout: null })
|
||||
}, 3000)
|
||||
|
||||
set({ hoverTimeout: timeoutId })
|
||||
},
|
||||
}))
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
|
||||
interface RouterTransitionState {
|
||||
isTransitioning: boolean
|
||||
startRouterTransition: () => void
|
||||
stopRouterTransition: () => void
|
||||
}
|
||||
|
||||
const useRouterTransitionStore = create<RouterTransitionState>((set) => ({
|
||||
isTransitioning: false,
|
||||
startRouterTransition: () => set(() => ({ isTransitioning: true })),
|
||||
stopRouterTransition: () => set(() => ({ isTransitioning: false })),
|
||||
}))
|
||||
|
||||
export default useRouterTransitionStore
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
|
||||
import {
|
||||
parseBookingWidgetSearchParams,
|
||||
searchParamsToRecord,
|
||||
} from "@scandic-hotels/booking-flow/utils/url"
|
||||
|
||||
import { checkIsSameBooking } from "./enter-details/helpers"
|
||||
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation"
|
||||
|
||||
interface TrackingStoreState {
|
||||
initialStartTime: number
|
||||
setInitialPageLoadTime: (time: number) => void
|
||||
getPageLoadTime: () => number
|
||||
currentParams: ReadonlyURLSearchParams | null
|
||||
previousParams: ReadonlyURLSearchParams | null
|
||||
currentPath: string | null
|
||||
previousPath: string | null
|
||||
currentLang: string | null
|
||||
previousLang: string | null
|
||||
updateRouteInfo: (
|
||||
path: string,
|
||||
lang: string,
|
||||
params: ReadonlyURLSearchParams
|
||||
) => void
|
||||
hasPathOrLangChanged: () => boolean
|
||||
hasBookingFlowParamsChanged: () => boolean
|
||||
}
|
||||
|
||||
const useTrackingStore = create<TrackingStoreState>((set, get) => ({
|
||||
initialStartTime: Date.now(),
|
||||
setInitialPageLoadTime: (time) => set({ initialStartTime: time }),
|
||||
getPageLoadTime: () => {
|
||||
const { initialStartTime } = get()
|
||||
return (Date.now() - initialStartTime) / 1000
|
||||
},
|
||||
currentParams: null,
|
||||
previousParams: null,
|
||||
currentPath: null,
|
||||
previousPath: null,
|
||||
currentLang: null,
|
||||
previousLang: null,
|
||||
updateRouteInfo: (path, lang, params) =>
|
||||
set((state) => {
|
||||
if (!path || !lang) return state
|
||||
|
||||
if (!state.currentPath || !state.currentLang) {
|
||||
return {
|
||||
currentParams: params,
|
||||
currentPath: path,
|
||||
currentLang: lang,
|
||||
previousParams: null,
|
||||
previousPath: null,
|
||||
previousLang: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previousParams: state.currentParams,
|
||||
previousPath: state.currentPath,
|
||||
previousLang: state.currentLang,
|
||||
currentParams: params,
|
||||
currentPath: path,
|
||||
currentLang: lang,
|
||||
}
|
||||
}),
|
||||
hasPathOrLangChanged: () => {
|
||||
const { currentPath, previousPath, currentLang, previousLang } = get()
|
||||
|
||||
if (!previousPath || !previousLang) return false
|
||||
|
||||
return currentPath !== previousPath || currentLang !== previousLang
|
||||
},
|
||||
hasBookingFlowParamsChanged: () => {
|
||||
const { currentPath, currentParams, previousParams } = get()
|
||||
|
||||
if (!previousParams || !currentParams) return false
|
||||
|
||||
if (!currentPath?.match(/^\/(da|de|en|fi|no|sv)\/(hotelreservation)/))
|
||||
return false
|
||||
|
||||
const previousParamsObject = parseBookingWidgetSearchParams(
|
||||
searchParamsToRecord(previousParams)
|
||||
)
|
||||
const currentParamsObject = parseBookingWidgetSearchParams(
|
||||
searchParamsToRecord(currentParams)
|
||||
)
|
||||
|
||||
if (!previousParamsObject && !currentParamsObject) return false
|
||||
if (!previousParamsObject || !currentParamsObject) return true
|
||||
|
||||
const isSameBooking = checkIsSameBooking(
|
||||
previousParamsObject,
|
||||
currentParamsObject
|
||||
)
|
||||
return !isSameBooking
|
||||
},
|
||||
}))
|
||||
|
||||
export default useTrackingStore
|
||||
@@ -1,18 +0,0 @@
|
||||
export type AvailabilityInput = {
|
||||
cityId: string
|
||||
roomStayStartDate: string
|
||||
roomStayEndDate: string
|
||||
adults: number
|
||||
children?: string
|
||||
bookingCode?: string
|
||||
redemption?: boolean
|
||||
}
|
||||
|
||||
export type AlternativeHotelsAvailabilityInput = {
|
||||
roomStayStartDate: string
|
||||
roomStayEndDate: string
|
||||
adults: number
|
||||
children?: string
|
||||
bookingCode?: string
|
||||
redemption?: boolean
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { CategorizedHotelFilters } from "./hotelFilters"
|
||||
|
||||
export type FilterAndSortModalProps = {
|
||||
filters: CategorizedHotelFilters
|
||||
setShowSkeleton?: (showSkeleton: boolean) => void
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type FilterCheckboxProps = {
|
||||
name: string
|
||||
id: string
|
||||
isDisabled?: boolean
|
||||
isSelected: boolean
|
||||
onChange: (filterId: string) => void
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ProductType } from "@scandic-hotels/trpc/types/availability"
|
||||
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
|
||||
export enum HotelCardListingTypeEnum {
|
||||
MapListing = "mapListing",
|
||||
PageListing = "pageListing",
|
||||
}
|
||||
|
||||
export type HotelData = {
|
||||
hotelData: Hotel
|
||||
price: ProductType
|
||||
}
|
||||
|
||||
export type HotelCardListingProps = {
|
||||
hotelData: HotelResponse[]
|
||||
unfilteredHotelCount: number
|
||||
type?: HotelCardListingTypeEnum
|
||||
isAlternative?: boolean
|
||||
}
|
||||
|
||||
export interface NullableHotelData extends Omit<HotelData, "hotelData"> {
|
||||
hotelData: HotelData["hotelData"] | null
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
|
||||
import type { HotelCardListingTypeEnum } from "./hotelCardListingProps"
|
||||
|
||||
export type HotelCardProps = {
|
||||
hotelData: HotelResponse
|
||||
isUserLoggedIn: boolean
|
||||
type?: HotelCardListingTypeEnum
|
||||
state?: "default" | "active"
|
||||
bookingCode?: string | null
|
||||
isAlternative?: boolean
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user