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:
Anton Gunnarsson
2025-09-01 08:37:00 +00:00
parent 93a90bef9d
commit 87402a2092
157 changed files with 2026 additions and 1376 deletions

View File

@@ -0,0 +1,28 @@
import { AlternativeHotelsMapPage as AlternativeHotelsMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsMapPage"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default async function AlternativeHotelsMapPage(
props: PageArgs<LangParams>
) {
const searchParams = await props.searchParams
const lang = await getLang()
return (
<div>
<AlternativeHotelsMapPagePrimitive
lang={lang}
searchParams={searchParams}
renderTracking={(props) => (
<TrackingSDK
hotelInfo={props.hotelsTrackingData}
pageData={props.pageTrackingData}
/>
)}
/>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { AlternativeHotelsPage as AlternativeHotelsPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsPage"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import { type LangParams, type PageArgs } from "@/types/params"
export default async function AlternativeHotelsPage(
props: PageArgs<LangParams>
) {
const searchParams = await props.searchParams
const lang = await getLang()
return (
<AlternativeHotelsPagePrimitive
lang={lang}
searchParams={searchParams}
renderTracking={(props) => (
<TrackingSDK
hotelInfo={props.hotelsTrackingData}
pageData={props.pageTrackingData}
/>
)}
/>
)
}

View File

@@ -0,0 +1,24 @@
import { SelectHotelMapPage as SelectHotelMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelMapPage"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage(props: PageArgs<LangParams>) {
const searchParams = await props.searchParams
const lang = await getLang()
return (
<SelectHotelMapPagePrimitive
lang={lang}
searchParams={searchParams}
renderTracking={(props) => (
<TrackingSDK
hotelInfo={props.hotelsTrackingData}
pageData={props.pageTrackingData}
/>
)}
/>
)
}

View File

@@ -1,4 +1,24 @@
export default async function SelectHotelPage() {
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
return <div>select-hotel</div>
import { SelectHotelPage as SelectHotelPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelPage"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelPage(props: PageArgs<LangParams>) {
const searchParams = await props.searchParams
const lang = await getLang()
return (
<SelectHotelPagePrimitive
lang={lang}
searchParams={searchParams}
renderTracking={(props) => (
<TrackingSDK
hotelInfo={props.hotelsTrackingData}
pageData={props.pageTrackingData}
/>
)}
/>
)
}

View File

@@ -4,10 +4,13 @@ import "@scandic-hotels/design-system/normalize.css"
import "@scandic-hotels/design-system/design-system-new-deprecated.css"
import "../../globals.css"
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
import { Lang } from "@scandic-hotels/common/constants/language"
import { TrpcProvider } from "@scandic-hotels/trpc/Provider"
import { serverClient } from "@/lib/trpc"
import { getMessages } from "@/i18n"
import ClientIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
@@ -15,6 +18,7 @@ import { setLang } from "@/i18n/serverContext"
import {
trackAccordionItemOpen,
trackBookingSearchClick,
trackGenericEvent,
trackOpenSidePeek,
} from "../utils/tracking"
@@ -43,6 +47,10 @@ export default async function RootLayout(props: RootLayoutProps) {
setLang(lang)
const messages = await getMessages(lang)
// TODO we need this import right now to ensure configureServerClient is called,
// but check where we do this
const _caller = await serverClient()
return (
<html lang="en">
<head>{/* TODO */}</head>
@@ -55,28 +63,36 @@ export default async function RootLayout(props: RootLayoutProps) {
>
{/* TODO handle onError */}
<TrpcProvider>
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
trackAccordionItemOpen,
trackOpenSidePeek,
<BookingFlowContextProvider
data={{
// TODO
isLoggedIn: false,
}}
>
<header
style={{
height: 64,
backgroundColor: "dodgerblue",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
<BookingFlowTrackingProvider
trackingFunctions={{
trackBookingSearchClick,
trackAccordionItemOpen,
trackOpenSidePeek,
trackGenericEvent,
}}
>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<h1>SAS</h1>
</header>
<main>{children}</main>
</BookingFlowTrackingProvider>
<header
style={{
height: 64,
backgroundColor: "dodgerblue",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<h1>SAS</h1>
</header>
<main>{children}</main>
</BookingFlowTrackingProvider>
</BookingFlowContextProvider>
</TrpcProvider>
</ClientIntlProvider>
</div>

View File

@@ -1,8 +1,6 @@
import { BookingWidget } from "@scandic-hotels/booking-flow/BookingWidget"
import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { serverClient } from "@/lib/trpc"
import { getLang } from "@/i18n/serverContext"
import type { Lang } from "@scandic-hotels/common/constants/language"
@@ -14,9 +12,6 @@ type SearchParams<S = {}> = {
export default async function Home(props: SearchParams<{ lang: Lang }>) {
const searchParams = await props.searchParams
// TODO we need this import right now to ensure configureServerClient is called,
// but we should ensure it's called in a layout instead.
const _caller = await serverClient()
const lang = await getLang()
const booking = parseBookingWidgetSearchParams(searchParams)

View File

@@ -22,3 +22,7 @@ export function trackOpenSidePeek(input: {
}) {
console.warn("TODO: Implement trackOpenSidePeek", { input })
}
export function trackGenericEvent(data: any) {
console.warn("TODO: Implement trackGenericEvent", { data })
}

View File

@@ -0,0 +1,55 @@
"use client"
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 "@scandic-hotels/common/tracking/types"
export default function TrackingSDK({
pageData,
hotelInfo,
paymentInfo,
ancillaries,
}: {
pageData: TrackingSDKPageData
hotelInfo?: TrackingSDKHotelInfo
paymentInfo?: TrackingSDKPaymentInfo
ancillaries?: TrackingSDKAncillaries
}) {
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
}

View File

@@ -0,0 +1,17 @@
"use client"
import { useParams } from "next/navigation"
import { Lang } from "@scandic-hotels/common/constants/language"
import { languageSchema } from "@scandic-hotels/common/utils/languages"
/**
* A hook to get the current lang from the URL
*/
export default function useLang() {
const { lang } = useParams<{
lang: Lang
}>()
const parsedLang = languageSchema.safeParse(lang)
return parsedLang.success ? parsedLang.data : Lang.en
}

View File

@@ -0,0 +1,19 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
type NextSearchParams = { [key: string]: string | string[] | undefined }
type SearchParams = {
searchParams: Promise<NextSearchParams>
}
type Params<P = {}> = {
params: Promise<P>
}
export type LangParams = {
lang: Lang
}
export type LayoutArgs<P = undefined> = P extends undefined ? {} : Params<P>
export type PageArgs<P = undefined> = LayoutArgs<P> & SearchParams

View File

@@ -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 />

View File

@@ -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>
)
}

View File

@@ -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>
</>
)}
/>
)
}

View File

@@ -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"

View File

@@ -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(

View File

@@ -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 />

View File

@@ -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>
)
}

View File

@@ -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>
</>
)}
/>
)
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -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()

View File

@@ -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"

View 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>
)
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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(

View File

@@ -1,6 +0,0 @@
.fnfMain {
max-width: var(--max-width-page);
margin: auto;
min-height: 30dvh;
padding: var(--Spacing-x5) 0;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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
),
}
})
}

View File

@@ -1,6 +0,0 @@
.hotelCards {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
}

View File

@@ -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>
)
}

View File

@@ -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))
}

View File

@@ -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()

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
</>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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}
/>
)
}

View File

@@ -1,2 +0,0 @@
export { default as FilterAndSortModal } from "./FilterAndSortModal"
export { default as HotelFilter } from "./HotelFilter"

View File

@@ -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>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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.",
})}
/>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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} />
</>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)
}

View File

@@ -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>
</>
)
}

View File

@@ -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);
}
}

View File

@@ -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,
}
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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(() => {

View File

@@ -1,5 +0,0 @@
.content {
display: grid;
gap: var(--Spacing-x2);
color: var(--Text-Default);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,
}
}

View File

@@ -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
}

View 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,
}
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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])
}

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More