Merged in feature/select-rate-vertical-data-flow (pull request #2535)
Feature/select rate vertical data flow * add fix from SW-2666 * use translations for room packages * move types to it's own file * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow * merge * feature/select-rate: double rate for campaing rates * revert NODE_ENV check in Cookiebot script * revert testing values * fix(SW-3171): fix all filter selected in price details * fix(SW-3166): multiroom anchoring when changing filter * fix(SW-3172): check hotelType, show correct breakfast message * Merge branch 'feature/select-rate-vertical-data-flow' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow * fix: show special needs icons for subsequent roomTypes SW-3167 * fix: Display strike through text when logged in SW-3168 * fix: Reinstate the scrollToView behaviour when selecting a rate SW-3169 * merge * . * PR fixes * fix: don't return notFound() * . * always include defaults for room packages * merge * merge * merge * Remove floating h1 for new select-rate Approved-by: Anton Gunnarsson
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.experimental.useTsgo": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default async function DetailsPage(
|
|||||||
|
|
||||||
if (!booking) return notFound()
|
if (!booking) return notFound()
|
||||||
|
|
||||||
if (selectRoomParams.has("modifyRateIndex")) {
|
if (selectRoomParams.has("activeRoomIndex")) {
|
||||||
selectRoomParams.delete("modifyRateIndex")
|
selectRoomParams.delete("activeRoomIndex")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||||
|
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
|
||||||
|
|
||||||
|
// Select Rate loading doesn't need a layout and wrapper
|
||||||
|
// to force loading.tsx to show again since refetch of
|
||||||
|
// availability happens client-side and only the RoomCards
|
||||||
|
// display a loading state since we already have the hotel
|
||||||
|
// data
|
||||||
|
export default function LoadingSelectRate() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HotelInfoCardSkeleton />
|
||||||
|
<RoomsContainerSkeleton />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { parseSelectRateSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||||
|
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||||
|
|
||||||
|
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
|
||||||
|
|
||||||
|
import SelectRate from "@/components/HotelReservation/SelectRate"
|
||||||
|
|
||||||
|
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
|
const singleRoomRateTypes = combineRegExps(
|
||||||
|
[rateTypeRegex.ARB, rateTypeRegex.VOUCHER],
|
||||||
|
"i"
|
||||||
|
)
|
||||||
|
|
||||||
|
export default async function SelectRatePage(
|
||||||
|
props: PageArgs<LangParams & { section: string }, NextSearchParams>
|
||||||
|
) {
|
||||||
|
const params = await props.params
|
||||||
|
const searchParams = await props.searchParams
|
||||||
|
const booking = parseSelectRateSearchParams(searchParams)
|
||||||
|
|
||||||
|
if (!booking) return notFound()
|
||||||
|
|
||||||
|
const isMultiRoom = booking.rooms.length > 1
|
||||||
|
const isRedemption = booking.searchType === SEARCH_TYPE_REDEMPTION
|
||||||
|
const isArbOrVoucher = booking.bookingCode
|
||||||
|
? singleRoomRateTypes.test(booking.bookingCode)
|
||||||
|
: false
|
||||||
|
|
||||||
|
if ((isMultiRoom && isRedemption) || (isMultiRoom && isArbOrVoucher)) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If someone tries to update the url with
|
||||||
|
// a bookingCode also, then we need to remove it
|
||||||
|
if (isRedemption && searchParams.bookingCode) {
|
||||||
|
delete searchParams.bookingCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SelectRate lang={params.lang} booking={booking} />
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { parseSelectRateSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
import { parseSelectRateSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||||
|
import { logger } from "@scandic-hotels/common/logger"
|
||||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||||
|
|
||||||
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
|
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
|
||||||
|
import { getHotel } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import SelectRate from "@/components/HotelReservation/SelectRate"
|
import SelectRate from "@/components/HotelReservation/SelectRate2"
|
||||||
|
import { SelectRateProvider } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
@@ -21,7 +24,10 @@ export default async function SelectRatePage(
|
|||||||
const searchParams = await props.searchParams
|
const searchParams = await props.searchParams
|
||||||
const booking = parseSelectRateSearchParams(searchParams)
|
const booking = parseSelectRateSearchParams(searchParams)
|
||||||
|
|
||||||
if (!booking) return notFound()
|
if (!booking) {
|
||||||
|
logger.debug("Invalid search params", searchParams)
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
const isMultiRoom = booking.rooms.length > 1
|
const isMultiRoom = booking.rooms.length > 1
|
||||||
const isRedemption = booking.searchType === SEARCH_TYPE_REDEMPTION
|
const isRedemption = booking.searchType === SEARCH_TYPE_REDEMPTION
|
||||||
@@ -30,7 +36,11 @@ export default async function SelectRatePage(
|
|||||||
: false
|
: false
|
||||||
|
|
||||||
if ((isMultiRoom && isRedemption) || (isMultiRoom && isArbOrVoucher)) {
|
if ((isMultiRoom && isRedemption) || (isMultiRoom && isArbOrVoucher)) {
|
||||||
return notFound()
|
logger.debug(
|
||||||
|
"Invalid search params, can't have multiroom and redemption/voucher",
|
||||||
|
{ isMultiRoom, isRedemption, isArbOrVoucher }
|
||||||
|
)
|
||||||
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If someone tries to update the url with
|
// If someone tries to update the url with
|
||||||
@@ -39,5 +49,20 @@ export default async function SelectRatePage(
|
|||||||
delete searchParams.bookingCode
|
delete searchParams.bookingCode
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SelectRate lang={params.lang} booking={booking} />
|
const hotelData = await getHotel({
|
||||||
|
hotelId: booking.hotelId,
|
||||||
|
isCardOnlyPayment: false,
|
||||||
|
language: params.lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hotelData) {
|
||||||
|
logger.debug("Unable to find hotel data")
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectRateProvider hotelData={hotelData}>
|
||||||
|
<SelectRate hotelData={hotelData} booking={booking} />
|
||||||
|
</SelectRateProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "@scandic-hotels/design-system/style.css"
|
|||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
import { SessionProvider } from "next-auth/react"
|
import { SessionProvider } from "next-auth/react"
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
|
|
||||||
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
|
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
|
||||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
@@ -65,27 +66,29 @@ export default async function RootLayout(
|
|||||||
locale={params.lang}
|
locale={params.lang}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
>
|
>
|
||||||
<TrpcProvider>
|
<NuqsAdapter>
|
||||||
<RACRouterProvider>
|
<TrpcProvider>
|
||||||
<BookingFlowTrackingProvider
|
<RACRouterProvider>
|
||||||
trackingFunctions={{
|
<BookingFlowTrackingProvider
|
||||||
trackBookingSearchClick,
|
trackingFunctions={{
|
||||||
}}
|
trackBookingSearchClick,
|
||||||
>
|
}}
|
||||||
<RouteChange />
|
>
|
||||||
<SitewideAlert />
|
<RouteChange />
|
||||||
<Header />
|
<SitewideAlert />
|
||||||
{bookingwidget}
|
<Header />
|
||||||
{children}
|
{bookingwidget}
|
||||||
<Footer />
|
{children}
|
||||||
<ToastHandler />
|
<Footer />
|
||||||
<SessionRefresher />
|
<ToastHandler />
|
||||||
<StorageCleaner />
|
<SessionRefresher />
|
||||||
<CookieBotConsent />
|
<StorageCleaner />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<CookieBotConsent />
|
||||||
</BookingFlowTrackingProvider>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</RACRouterProvider>
|
</BookingFlowTrackingProvider>
|
||||||
</TrpcProvider>
|
</RACRouterProvider>
|
||||||
|
</TrpcProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
</ClientIntlProvider>
|
</ClientIntlProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "@scandic-hotels/design-system/style.css"
|
|||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
import { SessionProvider } from "next-auth/react"
|
import { SessionProvider } from "next-auth/react"
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
|
|
||||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
|
|
||||||
@@ -52,15 +53,17 @@ export default async function RootLayout(
|
|||||||
locale={params.lang}
|
locale={params.lang}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
>
|
>
|
||||||
<TrpcProvider>
|
<NuqsAdapter>
|
||||||
<RouteChange />
|
<TrpcProvider>
|
||||||
{children}
|
<RouteChange />
|
||||||
<ToastHandler />
|
{children}
|
||||||
<SessionRefresher />
|
<ToastHandler />
|
||||||
<StorageCleaner />
|
<SessionRefresher />
|
||||||
<CookieBotConsent />
|
<StorageCleaner />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<CookieBotConsent />
|
||||||
</TrpcProvider>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</TrpcProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
</ClientIntlProvider>
|
</ClientIntlProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ export default async function RootLayout(
|
|||||||
data-culture={params.lang}
|
data-culture={params.lang}
|
||||||
id="Cookiebot"
|
id="Cookiebot"
|
||||||
src="https://consent.cookiebot.com/uc.js"
|
src="https://consent.cookiebot.com/uc.js"
|
||||||
|
async={true}
|
||||||
/>
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "@scandic-hotels/design-system/style.css"
|
|||||||
|
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
|
|
||||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
|
|
||||||
@@ -52,15 +53,17 @@ export default async function RootLayout(
|
|||||||
locale={params.lang}
|
locale={params.lang}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
>
|
>
|
||||||
<TrpcProvider>
|
<NuqsAdapter>
|
||||||
<RouteChange />
|
<TrpcProvider>
|
||||||
{children}
|
<RouteChange />
|
||||||
<ToastHandler />
|
{children}
|
||||||
<TokenRefresher />
|
<ToastHandler />
|
||||||
<StorageCleaner />
|
<TokenRefresher />
|
||||||
<CookieBotConsent />
|
<StorageCleaner />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<CookieBotConsent />
|
||||||
</TrpcProvider>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</TrpcProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
</ClientIntlProvider>
|
</ClientIntlProvider>
|
||||||
|
|
||||||
<Script
|
<Script
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "@/public/_static/css/design-system-new-deprecated.css"
|
|||||||
import "@scandic-hotels/design-system/style.css"
|
import "@scandic-hotels/design-system/style.css"
|
||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
|
|
||||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
|
|
||||||
@@ -56,11 +57,13 @@ export default async function RootLayout(
|
|||||||
locale={params.lang}
|
locale={params.lang}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
>
|
>
|
||||||
<TrpcProvider>
|
<NuqsAdapter>
|
||||||
<RouteChange />
|
<TrpcProvider>
|
||||||
{children}
|
<RouteChange />
|
||||||
<ToastHandler />
|
{children}
|
||||||
</TrpcProvider>
|
<ToastHandler />
|
||||||
|
</TrpcProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
</ClientIntlProvider>
|
</ClientIntlProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton
|
|||||||
import { MAP_RESTRICTIONS } from "@/constants/map"
|
import { MAP_RESTRICTIONS } from "@/constants/map"
|
||||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
|
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"
|
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||||
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
|
||||||
|
|
||||||
import { usePageType } from "../PageTypeProvider"
|
import { usePageType } from "../PageTypeProvider"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type ErrorBoundaryProps = {
|
|||||||
}
|
}
|
||||||
type ErrorBoundaryState = { hasError: boolean; error?: Error }
|
type ErrorBoundaryState = { hasError: boolean; error?: Error }
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component<
|
export class ErrorBoundary extends React.Component<
|
||||||
ErrorBoundaryProps,
|
ErrorBoundaryProps,
|
||||||
ErrorBoundaryState
|
ErrorBoundaryState
|
||||||
> {
|
> {
|
||||||
@@ -29,15 +29,27 @@ class ErrorBoundary extends React.Component<
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
if (this.props.fallback) {
|
const hasFallback = !!this.props.fallback
|
||||||
return this.props.fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
return (
|
||||||
return <h2>Something went wrong.</h2>
|
<>
|
||||||
|
{hasFallback && this.props.fallback}
|
||||||
|
{!hasFallback && (
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
<h2>Something went wrong.</h2>
|
||||||
|
)}
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
<button onClick={() => this.setState({ hasError: false })}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.props.fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children
|
return this.props.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ErrorBoundary
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
||||||
|
|
||||||
@@ -80,8 +81,8 @@ export default async function BookingConfirmation({
|
|||||||
<HotelDetails hotel={hotel} />
|
<HotelDetails hotel={hotel} />
|
||||||
{getHotelAlertsForBookingDates(
|
{getHotelAlertsForBookingDates(
|
||||||
hotel.specialAlerts,
|
hotel.specialAlerts,
|
||||||
booking.checkInDate,
|
dt(booking.checkInDate),
|
||||||
booking.checkOutDate
|
dt(booking.checkOutDate)
|
||||||
).map((alert) => (
|
).map((alert) => (
|
||||||
<div key={alert.id}>
|
<div key={alert.id}>
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { FormProvider, useForm } from "react-hook-form"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||||
import { bookingConfirmation,selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
import {
|
||||||
|
bookingConfirmation,
|
||||||
|
selectRate,
|
||||||
|
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||||
import { logger } from "@scandic-hotels/common/logger"
|
import { logger } from "@scandic-hotels/common/logger"
|
||||||
import Body from "@scandic-hotels/design-system/Body"
|
import Body from "@scandic-hotels/design-system/Body"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function SelectedRoom() {
|
|||||||
function changeRoom() {
|
function changeRoom() {
|
||||||
const searchParams = new URLSearchParams(searchParamsStr)
|
const searchParams = new URLSearchParams(searchParamsStr)
|
||||||
|
|
||||||
searchParams.set("modifyRateIndex", `${idx}`)
|
searchParams.set("activeRoomIndex", `${idx}`)
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`${selectRate(lang)}?${searchParams.toString()}`)
|
router.push(`${selectRate(lang)}?${searchParams.toString()}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
// import { describe, expect, test } from "@jest/globals"
|
|
||||||
// import { act, cleanup, render, screen, within } from "@testing-library/react"
|
|
||||||
// import { type IntlConfig, IntlProvider } from "react-intl"
|
|
||||||
|
|
||||||
// import { Lang } from "@scandic-hotels/common/constants/language"
|
|
||||||
|
|
||||||
// import {
|
|
||||||
// bedType,
|
|
||||||
// booking,
|
|
||||||
// breakfastPackage,
|
|
||||||
// guestDetailsMember,
|
|
||||||
// guestDetailsNonMember,
|
|
||||||
// roomPrice,
|
|
||||||
// roomRate,
|
|
||||||
// } from "@/__mocks__/hotelReservation"
|
|
||||||
// import { initIntl } from "@/i18n"
|
|
||||||
|
|
||||||
// import SummaryUI from "./UI"
|
|
||||||
|
|
||||||
// import type { PropsWithChildren } from "react"
|
|
||||||
|
|
||||||
// import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
|
||||||
// import { StepEnum } from "@/types/enums/step"
|
|
||||||
// import type { RoomState } from "@/types/stores/enter-details"
|
|
||||||
|
|
||||||
// jest.mock("@/lib/api", () => ({
|
|
||||||
// fetchRetry: jest.fn((fn) => fn),
|
|
||||||
// }))
|
|
||||||
|
|
||||||
// function createWrapper(intlConfig: IntlConfig) {
|
|
||||||
// return function Wrapper({ children }: PropsWithChildren) {
|
|
||||||
// return (
|
|
||||||
// <IntlProvider
|
|
||||||
// messages={intlConfig.messages}
|
|
||||||
// locale={intlConfig.locale}
|
|
||||||
// defaultLocale={intlConfig.defaultLocale}
|
|
||||||
// >
|
|
||||||
// {children}
|
|
||||||
// </IntlProvider>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const rooms: RoomState[] = [
|
|
||||||
// {
|
|
||||||
// currentStep: StepEnum.selectBed,
|
|
||||||
// isComplete: false,
|
|
||||||
// room: {
|
|
||||||
// adults: 2,
|
|
||||||
// bedType: {
|
|
||||||
// description: bedType.queen.description,
|
|
||||||
// roomTypeCode: bedType.queen.value,
|
|
||||||
// },
|
|
||||||
// bedTypes: [],
|
|
||||||
// breakfast: breakfastPackage,
|
|
||||||
// breakfastIncluded: false,
|
|
||||||
// cancellationRule: "",
|
|
||||||
// cancellationText: "Non-refundable",
|
|
||||||
// childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
|
||||||
// guest: guestDetailsNonMember,
|
|
||||||
// rateDetails: [],
|
|
||||||
// roomFeatures: [],
|
|
||||||
// roomPrice: roomPrice,
|
|
||||||
// roomRate: roomRate,
|
|
||||||
// roomType: "Standard",
|
|
||||||
// roomTypeCode: "QS",
|
|
||||||
// isAvailable: true,
|
|
||||||
// mustBeGuaranteed: false,
|
|
||||||
// isFlexRate: false,
|
|
||||||
// specialRequest: {
|
|
||||||
// comment: "",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// steps: {
|
|
||||||
// [StepEnum.selectBed]: {
|
|
||||||
// step: StepEnum.selectBed,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// [StepEnum.breakfast]: {
|
|
||||||
// step: StepEnum.breakfast,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// [StepEnum.details]: {
|
|
||||||
// step: StepEnum.details,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// currentStep: StepEnum.selectBed,
|
|
||||||
// isComplete: false,
|
|
||||||
// room: {
|
|
||||||
// adults: 1,
|
|
||||||
// bedType: {
|
|
||||||
// description: bedType.king.description,
|
|
||||||
// roomTypeCode: bedType.king.value,
|
|
||||||
// },
|
|
||||||
// bedTypes: [],
|
|
||||||
// breakfast: undefined,
|
|
||||||
// breakfastIncluded: false,
|
|
||||||
// cancellationText: "Non-refundable",
|
|
||||||
// childrenInRoom: [],
|
|
||||||
// guest: guestDetailsMember,
|
|
||||||
// rateDetails: [],
|
|
||||||
// roomFeatures: [],
|
|
||||||
// roomPrice: roomPrice,
|
|
||||||
// roomRate: roomRate,
|
|
||||||
// roomType: "Standard",
|
|
||||||
// roomTypeCode: "QS",
|
|
||||||
// isAvailable: true,
|
|
||||||
// mustBeGuaranteed: false,
|
|
||||||
// isFlexRate: false,
|
|
||||||
// specialRequest: {
|
|
||||||
// comment: "",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// steps: {
|
|
||||||
// [StepEnum.selectBed]: {
|
|
||||||
// step: StepEnum.selectBed,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// [StepEnum.breakfast]: {
|
|
||||||
// step: StepEnum.breakfast,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// [StepEnum.details]: {
|
|
||||||
// step: StepEnum.details,
|
|
||||||
// isValid: false,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
|
|
||||||
// describe("EnterDetails Summary", () => {
|
|
||||||
// afterEach(() => {
|
|
||||||
// cleanup()
|
|
||||||
// })
|
|
||||||
|
|
||||||
// test("render with single room correctly", async () => {
|
|
||||||
// const intl = await initIntl(Lang.en)
|
|
||||||
|
|
||||||
// await act(async () => {
|
|
||||||
// render(
|
|
||||||
// <SummaryUI
|
|
||||||
// booking={booking}
|
|
||||||
// rooms={rooms.slice(0, 1)}
|
|
||||||
// isMember={false}
|
|
||||||
// totalPrice={{
|
|
||||||
// requested: {
|
|
||||||
// currency: "EUR",
|
|
||||||
// price: 133,
|
|
||||||
// },
|
|
||||||
// local: {
|
|
||||||
// currency: "SEK",
|
|
||||||
// price: 1500,
|
|
||||||
// },
|
|
||||||
// }}
|
|
||||||
// vat={12}
|
|
||||||
// toggleSummaryOpen={jest.fn()}
|
|
||||||
// />,
|
|
||||||
// {
|
|
||||||
// wrapper: createWrapper(intl),
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// })
|
|
||||||
|
|
||||||
// screen.getByText("2 adults, 1 child")
|
|
||||||
// screen.getByText("Standard")
|
|
||||||
// screen.getByText("1,525 SEK")
|
|
||||||
// screen.getByText(bedType.queen.description)
|
|
||||||
// screen.getByText("Breakfast buffet")
|
|
||||||
// screen.getByText("1,500 SEK")
|
|
||||||
// screen.getByTestId("signup-promo-desktop")
|
|
||||||
// })
|
|
||||||
|
|
||||||
// test("render with multiple rooms correctly", async () => {
|
|
||||||
// const intl = await initIntl(Lang.en)
|
|
||||||
|
|
||||||
// await act(async () => {
|
|
||||||
// render(
|
|
||||||
// <SummaryUI
|
|
||||||
// booking={booking}
|
|
||||||
// rooms={rooms}
|
|
||||||
// isMember={false}
|
|
||||||
// totalPrice={{
|
|
||||||
// requested: {
|
|
||||||
// currency: "EUR",
|
|
||||||
// price: 133,
|
|
||||||
// },
|
|
||||||
// local: {
|
|
||||||
// currency: "SEK",
|
|
||||||
// price: 1500,
|
|
||||||
// },
|
|
||||||
// }}
|
|
||||||
// vat={12}
|
|
||||||
// toggleSummaryOpen={jest.fn()}
|
|
||||||
// />,
|
|
||||||
// {
|
|
||||||
// wrapper: createWrapper(intl),
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const room1 = within(screen.getByTestId("summary-room-1"))
|
|
||||||
// room1.getByText("Standard")
|
|
||||||
// room1.getByText("2 adults, 1 child")
|
|
||||||
// room1.getByText(bedType.queen.description)
|
|
||||||
// room1.getByText("Breakfast buffet")
|
|
||||||
|
|
||||||
// const room2 = within(screen.getByTestId("summary-room-2"))
|
|
||||||
// room2.getByText("Standard")
|
|
||||||
// room2.getByText("1 adult")
|
|
||||||
// const room2Breakfast = room2.queryByText("Breakfast buffet")
|
|
||||||
// expect(room2Breakfast).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
// room2.getByText(bedType.king.description)
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
@@ -205,8 +205,8 @@ export default async function MyStay(props: {
|
|||||||
|
|
||||||
hotel.specialAlerts = getHotelAlertsForBookingDates(
|
hotel.specialAlerts = getHotelAlertsForBookingDates(
|
||||||
hotel.specialAlerts,
|
hotel.specialAlerts,
|
||||||
fromDate,
|
dt(fromDate),
|
||||||
toDate
|
dt(toDate)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
|
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||||
@@ -31,10 +32,13 @@ export default async function HotelInfoCard({
|
|||||||
|
|
||||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||||
|
|
||||||
|
const bookingFromDate = dt(booking.fromDate)
|
||||||
|
const bookingToDate = dt(booking.toDate)
|
||||||
|
|
||||||
const specialAlerts = getHotelAlertsForBookingDates(
|
const specialAlerts = getHotelAlertsForBookingDates(
|
||||||
hotel.specialAlerts,
|
hotel.specialAlerts,
|
||||||
booking.fromDate,
|
bookingFromDate,
|
||||||
booking.toDate
|
bookingToDate
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,7 +157,7 @@ export function HotelInfoCardSkeleton() {
|
|||||||
>
|
>
|
||||||
<SkeletonShimmer width="20ch" />
|
<SkeletonShimmer width="20ch" />
|
||||||
</Typography>
|
</Typography>
|
||||||
{[1, 2, 3, 4, 5]?.map((id) => {
|
{[1, 2, 3, 4, 5].map((id) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.facilitiesItem} key={id}>
|
<div className={styles.facilitiesItem} key={id}>
|
||||||
<SkeletonShimmer width="10ch" />
|
<SkeletonShimmer width="10ch" />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default function Rates({
|
|||||||
nights,
|
nights,
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
const showAllRates = selectedFilter === BookingCodeFilterEnum.All
|
const showAllRates = selectedFilter === BookingCodeFilterEnum.All
|
||||||
const hasBookingCodeRates = !!(campaign.length || code.length)
|
const hasBookingCodeRates = !!(campaign.length || code.length)
|
||||||
const hasRegularRates = !!regular.length
|
const hasRegularRates = !!regular.length
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
|
||||||
|
|
||||||
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
|
|
||||||
|
export default function AvailabilityError() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const errorCode = searchParams.get("errorCode")
|
||||||
|
const hasAvailabilityError =
|
||||||
|
errorCode === BookingErrorCodeEnum.AvailabilityError
|
||||||
|
|
||||||
|
const errorMessage = intl.formatMessage({
|
||||||
|
defaultMessage:
|
||||||
|
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAvailabilityError) {
|
||||||
|
toast.error(errorMessage)
|
||||||
|
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString())
|
||||||
|
newParams.delete("errorCode")
|
||||||
|
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`)
|
||||||
|
}
|
||||||
|
}, [errorMessage, hasAvailabilityError, pathname, searchParams])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.hotelDescription {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
display: block;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandedContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--Space-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--Text-Interactive-Secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--Text-Interactive-Secondary-Hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilities {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilityList {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
padding-bottom: var(--Space-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilitiesItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.descriptionWrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button as ButtonRAC } from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
|
import ReadMore from "@/components/HotelReservation/ReadMore"
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
|
||||||
|
import styles from "./hotelDescription.module.css"
|
||||||
|
|
||||||
|
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
|
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||||
|
|
||||||
|
export default function HotelDescription({
|
||||||
|
description,
|
||||||
|
hotel,
|
||||||
|
sortedFacilities,
|
||||||
|
}: {
|
||||||
|
description?: string
|
||||||
|
hotel: Hotel
|
||||||
|
sortedFacilities: Hotel["detailedFacilities"]
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
setExpanded((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textShowMore = intl.formatMessage({
|
||||||
|
defaultMessage: "Show more",
|
||||||
|
})
|
||||||
|
|
||||||
|
const textShowLess = intl.formatMessage({
|
||||||
|
defaultMessage: "Show less",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.descriptionWrapper}>
|
||||||
|
<div className={styles.facilityList}>
|
||||||
|
{sortedFacilities?.map((facility) => (
|
||||||
|
<div className={styles.facilitiesItem} key={facility.id}>
|
||||||
|
<FacilityToIcon id={facility.id} color="Icon/Default" />
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p>{facility.name}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p
|
||||||
|
className={`${styles.hotelDescription} ${
|
||||||
|
expanded ? styles.expanded : styles.collapsed
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Link/md">
|
||||||
|
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
|
||||||
|
{expanded ? textShowLess : textShowMore}
|
||||||
|
</ButtonRAC>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className={styles.expandedContent}>
|
||||||
|
<ReadMore
|
||||||
|
label={intl.formatMessage({
|
||||||
|
defaultMessage: "See all amenities",
|
||||||
|
})}
|
||||||
|
hotelId={hotel.operaId}
|
||||||
|
showCTA={false}
|
||||||
|
sidePeekKey={SidePeekEnum.hotelDetails}
|
||||||
|
/>
|
||||||
|
{hotel.specialAlerts.map((alert) => (
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
type={alert.type}
|
||||||
|
heading={alert.heading}
|
||||||
|
text={alert.text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
.container {
|
||||||
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
|
padding: var(--Space-x3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelName {
|
||||||
|
color: var(--Text-Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelAddress {
|
||||||
|
color: var(--Text-Tertiary);
|
||||||
|
}
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
}
|
||||||
|
.hotelDescription {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.imageWrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelInformation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelAddressDescription {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelAlert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilities {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: var(--Space-x4) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.container {
|
||||||
|
padding: var(--Space-x4) var(--Space-x5);
|
||||||
|
}
|
||||||
|
.hotelDescription {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelAlert {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-top: var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilities {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--Space-x3) 0 var(--Space-x025);
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilityList {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
padding-bottom: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilitiesItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageWrapper {
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelContent {
|
||||||
|
gap: var(--Space-x6);
|
||||||
|
align-items: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelInformation {
|
||||||
|
padding-right: var(--Space-x3);
|
||||||
|
width: min(607px, 100%);
|
||||||
|
align-items: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelAddressDescription {
|
||||||
|
align-items: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
gap: var(--Space-x3);
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilities {
|
||||||
|
padding: var(--Space-x3) var(--Space-x3) var(--Space-x05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facilityList {
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
padding-bottom: var(--Space-x05);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.facilityTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hotelContent {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.imageWrapper {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
|
import ReadMore from "../../ReadMore"
|
||||||
|
import { getHotelAlertsForBookingDates } from "../../utils"
|
||||||
|
import HotelDescription from "./HotelDescription"
|
||||||
|
|
||||||
|
import styles from "./hotelInfoCard.module.css"
|
||||||
|
|
||||||
|
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
|
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||||
|
|
||||||
|
export type HotelInfoCardProps = {
|
||||||
|
booking: SelectRateBooking
|
||||||
|
hotel: Hotel
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HotelInfoCard({ booking, hotel }: HotelInfoCardProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
|
||||||
|
const sortedFacilities = hotel.detailedFacilities
|
||||||
|
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||||
|
|
||||||
|
const bookingFromDate = dt(booking.fromDate)
|
||||||
|
const bookingToDate = dt(booking.toDate)
|
||||||
|
const specialAlerts = getHotelAlertsForBookingDates(
|
||||||
|
hotel.specialAlerts,
|
||||||
|
bookingFromDate,
|
||||||
|
bookingToDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={styles.container}>
|
||||||
|
<section className={styles.wrapper}>
|
||||||
|
<div className={styles.imageWrapper}>
|
||||||
|
<ImageGallery title={hotel.name} images={galleryImages} fill />
|
||||||
|
{hotel.ratings?.tripAdvisor && (
|
||||||
|
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.hotelContent}>
|
||||||
|
<div className={styles.hotelInformation}>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h1 className={styles.hotelName}>{hotel.name}</h1>
|
||||||
|
</Typography>
|
||||||
|
<div className={styles.hotelAddressDescription}>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p className={styles.hotelAddress}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
address: hotel.address.streetAddress,
|
||||||
|
city: hotel.address.city,
|
||||||
|
distanceToCityCenterInKm: getSingleDecimal(
|
||||||
|
hotel.location.distanceToCentre / 1000
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p className={styles.hotelDescription}>
|
||||||
|
{hotel.hotelContent.texts.descriptions?.medium}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
<HotelDescription
|
||||||
|
key={hotel.operaId}
|
||||||
|
description={hotel.hotelContent.texts.descriptions?.medium}
|
||||||
|
hotel={hotel}
|
||||||
|
sortedFacilities={sortedFacilities}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider variant="vertical" />
|
||||||
|
<div className={styles.facilities}>
|
||||||
|
<div className={styles.facilityList}>
|
||||||
|
{sortedFacilities?.map((facility) => (
|
||||||
|
<div className={styles.facilitiesItem} key={facility.id}>
|
||||||
|
<FacilityToIcon id={facility.id} color="Icon/Default" />
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p>{facility.name}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ReadMore
|
||||||
|
label={intl.formatMessage({
|
||||||
|
defaultMessage: "See all amenities",
|
||||||
|
})}
|
||||||
|
hotelId={hotel.operaId}
|
||||||
|
showCTA={false}
|
||||||
|
sidePeekKey={SidePeekEnum.hotelDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{specialAlerts.map((alert) => (
|
||||||
|
<SpecialAlert key={alert.id} alert={alert} />
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpecialAlert({ alert }: { alert: Hotel["specialAlerts"][number] }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
type={alert.type}
|
||||||
|
heading={alert.heading}
|
||||||
|
text={alert.text}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HotelInfoCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<article className={styles.container}>
|
||||||
|
<section className={styles.wrapper}>
|
||||||
|
<div className={styles.imageWrapper}>
|
||||||
|
<SkeletonShimmer height="100%" width="100%" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.hotelContent}>
|
||||||
|
<div className={styles.hotelInformation}>
|
||||||
|
<SkeletonShimmer width="60ch" height="40px" />
|
||||||
|
<div className={styles.hotelAddressDescription}>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<SkeletonShimmer width="40ch" />
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
<SkeletonShimmer width="60ch" />
|
||||||
|
<SkeletonShimmer width="58ch" />
|
||||||
|
<SkeletonShimmer width="45ch" />
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider variant="vertical" />
|
||||||
|
<div className={styles.facilities}>
|
||||||
|
<div className={styles.facilityList}>
|
||||||
|
<Typography
|
||||||
|
variant="Body/Paragraph/mdBold"
|
||||||
|
className={styles.facilityTitle}
|
||||||
|
>
|
||||||
|
<SkeletonShimmer width="20ch" />
|
||||||
|
</Typography>
|
||||||
|
{[1, 2, 3, 4, 5]?.map((id) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.facilitiesItem} key={id}>
|
||||||
|
<SkeletonShimmer width="10ch" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={styles.hotelAlert}>
|
||||||
|
<SkeletonShimmer width="18ch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "./utils"
|
||||||
|
|
||||||
|
import styles from "./rateSummary.module.css"
|
||||||
|
|
||||||
|
import type { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import type { SelectedRate } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
export function DesktopSummary({
|
||||||
|
input,
|
||||||
|
selectedRates,
|
||||||
|
isSubmitting,
|
||||||
|
bookingCode,
|
||||||
|
}: {
|
||||||
|
selectedRates: ReturnType<typeof useSelectRateContext>["selectedRates"]
|
||||||
|
isSubmitting: boolean
|
||||||
|
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||||
|
bookingCode: string
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const isUserLoggedIn = useIsUserLoggedIn()
|
||||||
|
|
||||||
|
if (!selectedRates.totalPrice) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMemberRates = selectedRates.rates.some(
|
||||||
|
(rate) => rate && "member" in rate && rate.member
|
||||||
|
)
|
||||||
|
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||||
|
|
||||||
|
const totalNights = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||||
|
},
|
||||||
|
{ totalNights: input.nights }
|
||||||
|
)
|
||||||
|
const totalAdults = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
totalAdults:
|
||||||
|
input.data?.booking.rooms.reduce((acc, room) => acc + room.adults, 0) ??
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const childrenInOneOrMoreRooms = input.data?.booking.rooms.some(
|
||||||
|
(room) => room.childrenInRoom?.length
|
||||||
|
)
|
||||||
|
const childrenInroom = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{totalChildren, plural, one {# child} other {# children}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
totalChildren: input.data?.booking.rooms.reduce(
|
||||||
|
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||||
|
const totalRooms = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||||
|
},
|
||||||
|
{ totalRooms: input.roomCount }
|
||||||
|
)
|
||||||
|
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||||
|
const isAllRoomsSelected = selectedRates.state === "ALL_SELECTED"
|
||||||
|
|
||||||
|
const showDiscounted =
|
||||||
|
isUserLoggedIn || selectedRates.rates.some(isBookingCodeRate)
|
||||||
|
|
||||||
|
const mainRoomRate = selectedRates.rates.at(0)
|
||||||
|
let mainRoomCurrency = getRoomCurrency(mainRoomRate)
|
||||||
|
|
||||||
|
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||||
|
? selectedRates.totalPrice.local.regularPrice
|
||||||
|
: 0
|
||||||
|
const isTotalRegularPriceGreaterThanPrice =
|
||||||
|
totalRegularPrice > selectedRates.totalPrice.local.price
|
||||||
|
const showStrikedThroughPrice =
|
||||||
|
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.summaryText}>
|
||||||
|
{selectedRates.rates.map((room, index) => {
|
||||||
|
return (
|
||||||
|
<RateSummary
|
||||||
|
key={index}
|
||||||
|
room={room}
|
||||||
|
roomIndex={index}
|
||||||
|
isMultiRoom={selectedRates.rates.length > 1}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPriceContainer}>
|
||||||
|
{showMemberDiscountBanner && (
|
||||||
|
<div className={styles.promoContainer}>
|
||||||
|
<SignupPromoDesktop
|
||||||
|
memberPrice={{
|
||||||
|
amount: selectedRates.rates.reduce((total, rate) => {
|
||||||
|
if (!rate) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberExists = "member" in rate && rate.member
|
||||||
|
const publicExists = "public" in rate && rate.public
|
||||||
|
if (!memberExists && !publicExists) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
const price =
|
||||||
|
rate.member?.localPrice.pricePerStay ||
|
||||||
|
rate.public?.localPrice.pricePerStay
|
||||||
|
if (!price) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
const selectedPackagesPrice =
|
||||||
|
rate.roomInfo.selectedPackages.reduce(
|
||||||
|
(acc, pkg) => acc + pkg.localPrice.totalPrice,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return total + price + selectedPackagesPrice
|
||||||
|
}, 0),
|
||||||
|
currency: mainRoomCurrency ?? "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.summaryPriceTextDesktop}>
|
||||||
|
<Body>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||||
|
},
|
||||||
|
{ b: (str) => <b>{str}</b> }
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPrice}>
|
||||||
|
<div className={styles.summaryPriceTextDesktop}>
|
||||||
|
<Subtitle
|
||||||
|
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.price,
|
||||||
|
selectedRates.totalPrice.local.currency,
|
||||||
|
selectedRates.totalPrice.local.additionalPrice,
|
||||||
|
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
{showStrikedThroughPrice &&
|
||||||
|
selectedRates.totalPrice.local.regularPrice && (
|
||||||
|
<Caption
|
||||||
|
textAlign="right"
|
||||||
|
color="uiTextMediumContrast"
|
||||||
|
striked={true}
|
||||||
|
>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.regularPrice,
|
||||||
|
selectedRates.totalPrice.local.currency
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
)}
|
||||||
|
{selectedRates.totalPrice.requested ? (
|
||||||
|
<Body color="uiTextMediumContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Approx. {value}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.requested.price,
|
||||||
|
selectedRates.totalPrice.requested.currency,
|
||||||
|
selectedRates.totalPrice.requested.additionalPrice,
|
||||||
|
selectedRates.totalPrice.requested.additionalPriceCurrency
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryPriceTextMobile}>
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Total price",
|
||||||
|
})}
|
||||||
|
</Caption>
|
||||||
|
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.price,
|
||||||
|
selectedRates.totalPrice.local.currency,
|
||||||
|
selectedRates.totalPrice.local.additionalPrice,
|
||||||
|
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Footnote
|
||||||
|
color="uiTextMediumContrast"
|
||||||
|
className={styles.summaryPriceTextMobile}
|
||||||
|
>
|
||||||
|
{summaryPriceText}
|
||||||
|
</Footnote>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={styles.continueButton}
|
||||||
|
disabled={!isAllRoomsSelected || isSubmitting}
|
||||||
|
theme="base"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Continue",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RateSummary({
|
||||||
|
roomIndex,
|
||||||
|
room,
|
||||||
|
isMultiRoom,
|
||||||
|
}: {
|
||||||
|
room: SelectedRate | undefined
|
||||||
|
roomIndex: number
|
||||||
|
isMultiRoom: boolean
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const getRateDetails = useRateDetails()
|
||||||
|
|
||||||
|
if (!room || !room.isSelected) {
|
||||||
|
return (
|
||||||
|
<div key={`unselected-${roomIndex}`}>
|
||||||
|
<Subtitle color="uiTextPlaceholder">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{ roomIndex: roomIndex + 1 }
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextPlaceholder">
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Select room",
|
||||||
|
})}
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={roomIndex}>
|
||||||
|
{isMultiRoom ? (
|
||||||
|
<>
|
||||||
|
<Subtitle color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{ roomIndex: roomIndex + 1 }
|
||||||
|
)}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextMediumContrast">{room.roomInfo.roomType}</Body>
|
||||||
|
<Caption color="uiTextMediumContrast">
|
||||||
|
{getRateDetails(room.rate)}
|
||||||
|
</Caption>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Subtitle color="uiTextHighContrast">
|
||||||
|
{room.roomInfo.roomType}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextMediumContrast">{getRateDetails(room.rate)}</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRateDetails() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const freeCancelation = intl.formatMessage({
|
||||||
|
defaultMessage: "Free cancellation",
|
||||||
|
})
|
||||||
|
const nonRefundable = intl.formatMessage({
|
||||||
|
defaultMessage: "Non-refundable",
|
||||||
|
})
|
||||||
|
const freeBooking = intl.formatMessage({
|
||||||
|
defaultMessage: "Free rebooking",
|
||||||
|
})
|
||||||
|
const payLater = intl.formatMessage({
|
||||||
|
defaultMessage: "Pay later",
|
||||||
|
})
|
||||||
|
const payNow = intl.formatMessage({
|
||||||
|
defaultMessage: "Pay now",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (rate: RateEnum) => {
|
||||||
|
switch (rate) {
|
||||||
|
case RateEnum.change:
|
||||||
|
return `${freeBooking}, ${payNow}`
|
||||||
|
case RateEnum.flex:
|
||||||
|
return `${freeCancelation}, ${payLater}`
|
||||||
|
case RateEnum.save:
|
||||||
|
default:
|
||||||
|
return `${nonRefundable}, ${payNow}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomCurrency(rate: SelectedRate | undefined) {
|
||||||
|
if (!rate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("member" in rate && rate.member?.localPrice) {
|
||||||
|
return rate.member.localPrice.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("public" in rate && rate.public?.localPrice) {
|
||||||
|
return rate.public.localPrice.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"use client"
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
|
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
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 PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||||
|
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "../../utils"
|
||||||
|
import Room from "../Room"
|
||||||
|
|
||||||
|
import styles from "./summaryContent.module.css"
|
||||||
|
|
||||||
|
import type { Price } from "@/contexts/SelectRate/getTotalPrice"
|
||||||
|
|
||||||
|
export type SelectRateSummaryProps = {
|
||||||
|
isMember: boolean
|
||||||
|
bookingCode?: string
|
||||||
|
toggleSummaryOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SummaryContent({
|
||||||
|
isMember,
|
||||||
|
toggleSummaryOpen,
|
||||||
|
}: SelectRateSummaryProps) {
|
||||||
|
const { selectedRates, input } = useSelectRateContext()
|
||||||
|
|
||||||
|
const intl = useIntl()
|
||||||
|
const lang = useLang()
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
|
||||||
|
const nightsLabel = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||||
|
},
|
||||||
|
{ totalNights: input.nights }
|
||||||
|
)
|
||||||
|
|
||||||
|
const memberPrice =
|
||||||
|
selectedRates.rates.length === 1 &&
|
||||||
|
selectedRates.rates[0] &&
|
||||||
|
"member" in selectedRates.rates[0]
|
||||||
|
? selectedRates.rates[0].member
|
||||||
|
: null
|
||||||
|
|
||||||
|
const containsBookingCodeRate = selectedRates.rates.find(
|
||||||
|
(r) => r && isBookingCodeRate(r)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedRates?.totalPrice) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDiscounted = containsBookingCodeRate || isMember
|
||||||
|
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
|
||||||
|
? selectedRates.totalPrice.local.regularPrice
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const showStrikeThroughPrice =
|
||||||
|
totalRegularPrice > selectedRates?.totalPrice?.local?.price
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.summary}>
|
||||||
|
<header>
|
||||||
|
<div className={styles.headingWrapper}>
|
||||||
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<h3 className={styles.heading}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Booking summary",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
className={styles.closeButton}
|
||||||
|
onPress={toggleSummaryOpen}
|
||||||
|
theme="Black"
|
||||||
|
style="Muted"
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p className={styles.dates}>
|
||||||
|
{dt(input.data?.booking.fromDate)
|
||||||
|
.locale(lang)
|
||||||
|
.format(longDateFormat[lang])}
|
||||||
|
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
|
||||||
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
|
{dt(input.data?.booking.toDate)
|
||||||
|
.locale(lang)
|
||||||
|
.format(longDateFormat[lang])}{" "}
|
||||||
|
({nightsLabel})
|
||||||
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Divider color="Border/Divider/Subtle" />
|
||||||
|
|
||||||
|
{selectedRates.rates.map((room, idx) => {
|
||||||
|
if (!room) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Room
|
||||||
|
key={idx}
|
||||||
|
room={mapToRoom({
|
||||||
|
isMember,
|
||||||
|
rate: room,
|
||||||
|
input,
|
||||||
|
idx,
|
||||||
|
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||||
|
rateTitles,
|
||||||
|
})}
|
||||||
|
roomNumber={idx + 1}
|
||||||
|
roomCount={selectedRates.rates.length}
|
||||||
|
isMember={isMember}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className={styles.entry}>
|
||||||
|
<div>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b: (str) => (
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<span>{str}</span>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
{selectedRates.totalPrice.requested ? (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p className={styles.approxPrice}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Approx. {value}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.requested.price,
|
||||||
|
selectedRates.totalPrice.requested.currency,
|
||||||
|
selectedRates.totalPrice.requested.additionalPrice,
|
||||||
|
selectedRates.totalPrice.requested
|
||||||
|
.additionalPriceCurrency
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.prices}>
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<span
|
||||||
|
className={cx(styles.price, {
|
||||||
|
[styles.discounted]: showDiscounted,
|
||||||
|
})}
|
||||||
|
data-testid="total-price"
|
||||||
|
>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.price,
|
||||||
|
selectedRates.totalPrice.local.currency,
|
||||||
|
selectedRates.totalPrice.local.additionalPrice,
|
||||||
|
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
{showDiscounted &&
|
||||||
|
showStrikeThroughPrice &&
|
||||||
|
selectedRates.totalPrice.local.regularPrice ? (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<s className={styles.strikeThroughRate}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.regularPrice,
|
||||||
|
selectedRates.totalPrice.local.currency
|
||||||
|
)}
|
||||||
|
</s>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PriceDetailsModal
|
||||||
|
bookingCode={input.bookingCode}
|
||||||
|
defaultCurrency={
|
||||||
|
selectedRates.totalPrice.requested?.currency ??
|
||||||
|
selectedRates.totalPrice.local.currency
|
||||||
|
}
|
||||||
|
rooms={selectedRates.rates
|
||||||
|
.map((room, idx) => {
|
||||||
|
if (!room) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = mapToRoom({
|
||||||
|
isMember,
|
||||||
|
rate: room,
|
||||||
|
input,
|
||||||
|
idx,
|
||||||
|
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||||
|
rateTitles,
|
||||||
|
})
|
||||||
|
|
||||||
|
function getPrice(
|
||||||
|
room: NonNullable<(typeof selectedRates.rates)[number]>,
|
||||||
|
isMember: boolean
|
||||||
|
) {
|
||||||
|
switch (room.type) {
|
||||||
|
case "regular":
|
||||||
|
return {
|
||||||
|
regular: isMember
|
||||||
|
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||||
|
: room.public?.localPrice,
|
||||||
|
}
|
||||||
|
case "campaign":
|
||||||
|
return {
|
||||||
|
campaign: isMember
|
||||||
|
? (room.member ?? room.public)
|
||||||
|
: room.public,
|
||||||
|
}
|
||||||
|
case "redemption":
|
||||||
|
return {
|
||||||
|
redemption: room.redemption,
|
||||||
|
}
|
||||||
|
case "code": {
|
||||||
|
if ("corporateCheque" in room) {
|
||||||
|
return {
|
||||||
|
corporateCheque: room.corporateCheque,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("voucher" in room) {
|
||||||
|
return {
|
||||||
|
voucher: room.voucher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("public" in room) {
|
||||||
|
return {
|
||||||
|
regular: isMember
|
||||||
|
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||||
|
: room.public?.localPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown price type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = getPrice(room!, isMember)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
idx,
|
||||||
|
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||||
|
rateTitles,
|
||||||
|
price: p,
|
||||||
|
bedType: undefined,
|
||||||
|
breakfast: undefined,
|
||||||
|
breakfastIncluded:
|
||||||
|
room?.rateDefinition.breakfastIncluded ?? false,
|
||||||
|
rateDefinition: room.rateDefinition,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((x) => !!x)}
|
||||||
|
fromDate={input.data?.booking.fromDate ?? ""}
|
||||||
|
toDate={input.data?.booking.toDate ?? ""}
|
||||||
|
totalPrice={selectedRates.totalPrice}
|
||||||
|
vat={selectedRates.vat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isMember && memberPrice ? (
|
||||||
|
<SignupPromoDesktop
|
||||||
|
memberPrice={{
|
||||||
|
amount: memberPrice.localPrice.pricePerStay,
|
||||||
|
currency: memberPrice.localPrice.currency,
|
||||||
|
}}
|
||||||
|
badgeContent={"✌️"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToRoom({
|
||||||
|
isMember,
|
||||||
|
rate,
|
||||||
|
input,
|
||||||
|
idx,
|
||||||
|
getPriceForRoom,
|
||||||
|
rateTitles,
|
||||||
|
}: {
|
||||||
|
isMember: boolean
|
||||||
|
rate: NonNullable<
|
||||||
|
ReturnType<typeof useSelectRateContext>["selectedRates"]["rates"][number]
|
||||||
|
>
|
||||||
|
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||||
|
idx: number
|
||||||
|
getPriceForRoom: (roomIndex: number) => Price | null
|
||||||
|
rateTitles: ReturnType<typeof useRateTitles>
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
adults: input.data?.booking.rooms[idx].adults || 0,
|
||||||
|
childrenInRoom: input.data?.booking.rooms[idx].childrenInRoom,
|
||||||
|
roomType: rate.roomInfo.roomType,
|
||||||
|
roomRate: rate,
|
||||||
|
cancellationText: rateTitles[rate.rate].title,
|
||||||
|
roomPrice: {
|
||||||
|
perNight: { local: { price: -1, currency: CurrencyEnum.SEK } },
|
||||||
|
perStay: getPriceForRoom(idx) ?? {
|
||||||
|
local: { price: -1, currency: CurrencyEnum.Unknown },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rateDetails: isMember
|
||||||
|
? (rate.rateDefinitionMember?.generalTerms ??
|
||||||
|
rate.rateDefinition.generalTerms)
|
||||||
|
: rate.rateDefinition.generalTerms,
|
||||||
|
packages: rate.roomInfo.selectedPackages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.summary {
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
padding: var(--Space-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headingWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
margin-top: -10px; /* Compensate for padding of the button */
|
||||||
|
margin-right: -10px; /* Compensate for padding of the button */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dates {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--Text-Accent-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prices {
|
||||||
|
justify-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
|
||||||
|
&.discounted {
|
||||||
|
color: var(--Text-Accent-Primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.strikeThroughRate {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approxPrice {
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||||
|
|
||||||
|
import Modal from "@/components/Modal"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "../../utils"
|
||||||
|
import { getMemberPrice } from "../utils"
|
||||||
|
|
||||||
|
import styles from "./room.module.css"
|
||||||
|
|
||||||
|
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||||
|
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RoomPrice,
|
||||||
|
RoomRate,
|
||||||
|
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
|
|
||||||
|
interface RoomProps {
|
||||||
|
room: {
|
||||||
|
adults: number
|
||||||
|
childrenInRoom: Child[] | undefined
|
||||||
|
roomType: string
|
||||||
|
roomPrice: RoomPrice
|
||||||
|
roomRate: RoomRate
|
||||||
|
rateDetails: string[] | undefined
|
||||||
|
cancellationText: string
|
||||||
|
packages?: Packages
|
||||||
|
}
|
||||||
|
roomNumber: number
|
||||||
|
roomCount: number
|
||||||
|
isMember: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Room({
|
||||||
|
room,
|
||||||
|
roomNumber,
|
||||||
|
roomCount,
|
||||||
|
isMember,
|
||||||
|
}: RoomProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const adults = room.adults
|
||||||
|
const childrenInRoom = room.childrenInRoom
|
||||||
|
|
||||||
|
const childrenBeds = childrenInRoom?.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
const bedType = Number(value.bed)
|
||||||
|
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
const count = acc.get(bedType) ?? 0
|
||||||
|
acc.set(bedType, count + 1)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
new Map<ChildBedMapEnum, number>([
|
||||||
|
[ChildBedMapEnum.IN_CRIB, 0],
|
||||||
|
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||||
|
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||||
|
|
||||||
|
const memberPrice = getMemberPrice(room.roomRate)
|
||||||
|
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||||
|
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||||
|
|
||||||
|
const adultsMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||||
|
},
|
||||||
|
{ totalAdults: adults }
|
||||||
|
)
|
||||||
|
|
||||||
|
const guestsParts = [adultsMsg]
|
||||||
|
if (childrenInRoom?.length) {
|
||||||
|
const childrenMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{totalChildren, plural, one {# child} other {# children}}",
|
||||||
|
},
|
||||||
|
{ totalChildren: childrenInRoom.length }
|
||||||
|
)
|
||||||
|
guestsParts.push(childrenMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomPackages = room.packages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
|
||||||
|
<div>
|
||||||
|
{roomCount > 1 ? (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<p className={styles.roomTitle}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roomIndex: roomNumber,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.entry}>
|
||||||
|
<div>
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>{room.roomType}</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<div className={styles.additionalInformation}>
|
||||||
|
<p>{guestsParts.join(", ")}</p>
|
||||||
|
<p>{room.cancellationText}</p>
|
||||||
|
</div>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<div className={styles.prices}>
|
||||||
|
<p
|
||||||
|
className={cx(styles.price, {
|
||||||
|
[styles.discounted]: showDiscounted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{showMemberPrice
|
||||||
|
? formatPrice(
|
||||||
|
intl,
|
||||||
|
memberPrice.amount,
|
||||||
|
memberPrice.currency
|
||||||
|
)
|
||||||
|
: formatPrice(
|
||||||
|
intl,
|
||||||
|
room.roomPrice.perStay.local.price,
|
||||||
|
room.roomPrice.perStay.local.currency,
|
||||||
|
room.roomPrice.perStay.local.additionalPrice,
|
||||||
|
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{showDiscounted && room.roomPrice.perStay.local.price ? (
|
||||||
|
<s className={styles.strikeThroughRate}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
room.roomPrice.perStay.local.price,
|
||||||
|
room.roomPrice.perStay.local.currency
|
||||||
|
)}
|
||||||
|
</s>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{room.rateDetails?.length ? (
|
||||||
|
<div className={styles.ctaWrapper}>
|
||||||
|
<Modal
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
className={styles.termsButton}
|
||||||
|
variant="Text"
|
||||||
|
typography="Body/Supporting text (caption)/smBold"
|
||||||
|
wrapping={false}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Rate details",
|
||||||
|
})}
|
||||||
|
<MaterialIcon
|
||||||
|
icon="chevron_right"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={room.cancellationText}
|
||||||
|
>
|
||||||
|
<div className={styles.terms}>
|
||||||
|
{room.rateDetails.map((info) => (
|
||||||
|
<Typography key={info} variant="Body/Paragraph/mdRegular">
|
||||||
|
<p className={styles.termsText}>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="check"
|
||||||
|
color="Icon/Feedback/Success"
|
||||||
|
size={20}
|
||||||
|
className={styles.termsIcon}
|
||||||
|
/>
|
||||||
|
{info}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{childBedCrib ? (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<div className={styles.entry}>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Crib (child) × {count}",
|
||||||
|
},
|
||||||
|
{ count: childBedCrib }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Subject to availability",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prices}>
|
||||||
|
<span className={styles.price}>
|
||||||
|
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{childBedExtraBed ? (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<div className={styles.entry}>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Extra bed (child) × {count}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: childBedExtraBed,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Subject to availability",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prices}>
|
||||||
|
<span className={styles.price}>
|
||||||
|
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{roomPackages?.map((pkg) => (
|
||||||
|
<Typography key={pkg.code} variant="Body/Paragraph/mdRegular">
|
||||||
|
<div className={styles.entry}>
|
||||||
|
<p>{pkg.description}</p>
|
||||||
|
<div className={styles.prices}>
|
||||||
|
<span className={styles.price}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
pkg.localPrice.price,
|
||||||
|
pkg.localPrice.currency
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Divider color="Border/Divider/Subtle" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.room {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomTitle,
|
||||||
|
.additionalInformation {
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms {
|
||||||
|
margin-top: var(--Space-x3);
|
||||||
|
margin-bottom: var(--Space-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.termsText:nth-child(n) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms .termsIcon {
|
||||||
|
margin-right: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prices {
|
||||||
|
justify-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
|
||||||
|
&.discounted {
|
||||||
|
color: var(--Text-Accent-Primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.strikeThroughRate {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaWrapper {
|
||||||
|
margin-top: var(--Space-x15);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client"
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Button as ButtonRAC } from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "../utils"
|
||||||
|
import SummaryContent from "./Content"
|
||||||
|
|
||||||
|
import styles from "./mobileSummary.module.css"
|
||||||
|
|
||||||
|
export function MobileSummary() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const scrollY = useRef(0)
|
||||||
|
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||||
|
const isUserLoggedIn = useIsUserLoggedIn()
|
||||||
|
|
||||||
|
const { selectedRates } = useSelectRateContext()
|
||||||
|
|
||||||
|
function toggleSummaryOpen() {
|
||||||
|
setIsSummaryOpen(!isSummaryOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSummaryOpen) {
|
||||||
|
scrollY.current = window.scrollY
|
||||||
|
document.body.style.position = "fixed"
|
||||||
|
document.body.style.top = `-${scrollY.current}px`
|
||||||
|
document.body.style.width = "100%"
|
||||||
|
} else {
|
||||||
|
document.body.style.position = ""
|
||||||
|
document.body.style.top = ""
|
||||||
|
document.body.style.width = ""
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollY.current,
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.position = ""
|
||||||
|
document.body.style.top = ""
|
||||||
|
document.body.style.width = ""
|
||||||
|
}
|
||||||
|
}, [isSummaryOpen])
|
||||||
|
|
||||||
|
const containsBookingCodeRate = selectedRates.rates.find(
|
||||||
|
(r) => r && isBookingCodeRate(r)
|
||||||
|
)
|
||||||
|
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||||
|
|
||||||
|
if (!selectedRates.totalPrice) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||||
|
? selectedRates.totalPrice.local.regularPrice
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const showStrikeThroughPrice =
|
||||||
|
totalRegularPrice > selectedRates.totalPrice.local?.price
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.summaryAccordion}>
|
||||||
|
<SummaryContent
|
||||||
|
isMember={isUserLoggedIn}
|
||||||
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bottomSheet}>
|
||||||
|
<ButtonRAC
|
||||||
|
data-open={isSummaryOpen}
|
||||||
|
onPress={toggleSummaryOpen}
|
||||||
|
className={styles.priceDetailsButton}
|
||||||
|
>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<span className={styles.priceLabel}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Total price",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Title/Subtitle/lg">
|
||||||
|
<span
|
||||||
|
className={cx(styles.price, {
|
||||||
|
[styles.discounted]: showDiscounted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.price,
|
||||||
|
selectedRates.totalPrice.local.currency,
|
||||||
|
selectedRates.totalPrice.local.additionalPrice,
|
||||||
|
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
{showDiscounted &&
|
||||||
|
showStrikeThroughPrice &&
|
||||||
|
selectedRates.totalPrice.local.regularPrice ? (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<s className={styles.strikeThroughRate}>
|
||||||
|
{formatPrice(
|
||||||
|
intl,
|
||||||
|
selectedRates.totalPrice.local.regularPrice,
|
||||||
|
selectedRates.totalPrice.local.currency
|
||||||
|
)}
|
||||||
|
</s>
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<span className={styles.seeDetails}>
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "See details",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="chevron_right"
|
||||||
|
color="CurrentColor"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</ButtonRAC>
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
color="Primary"
|
||||||
|
size="Large"
|
||||||
|
type="submit"
|
||||||
|
typography="Body/Paragraph/mdBold"
|
||||||
|
isDisabled={selectedRates.state !== "ALL_SELECTED"}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Continue",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
|
|
||||||
|
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type { Price } from "@/types/components/hotelReservation/price"
|
||||||
|
import type {
|
||||||
|
Rate,
|
||||||
|
Room,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
|
||||||
|
export function mapRate(
|
||||||
|
room: Rate,
|
||||||
|
index: number,
|
||||||
|
bookingRooms: Room[],
|
||||||
|
packages: NonNullable<Packages>
|
||||||
|
) {
|
||||||
|
const rate = {
|
||||||
|
adults: bookingRooms[index].adults,
|
||||||
|
cancellationText: room.product.rateDefinition?.cancellationText ?? "",
|
||||||
|
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
|
||||||
|
rateDetails: room.product.rateDefinition?.generalTerms,
|
||||||
|
roomPrice: {
|
||||||
|
currency: CurrencyEnum.Unknown,
|
||||||
|
perNight: <Price>{
|
||||||
|
local: {
|
||||||
|
currency: CurrencyEnum.Unknown,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
},
|
||||||
|
perStay: <Price>{
|
||||||
|
local: {
|
||||||
|
currency: CurrencyEnum.Unknown,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roomRate: room.product,
|
||||||
|
roomType: room.roomType,
|
||||||
|
packages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("corporateCheque" in room.product) {
|
||||||
|
rate.roomPrice.currency = CurrencyEnum.CC
|
||||||
|
rate.roomPrice.perNight.local = {
|
||||||
|
currency: CurrencyEnum.CC,
|
||||||
|
price: room.product.corporateCheque.localPrice.numberOfCheques,
|
||||||
|
additionalPrice:
|
||||||
|
room.product.corporateCheque.localPrice.additionalPricePerStay,
|
||||||
|
additionalPriceCurrency:
|
||||||
|
room.product.corporateCheque.localPrice.currency ??
|
||||||
|
CurrencyEnum.Unknown,
|
||||||
|
}
|
||||||
|
rate.roomPrice.perStay.local = {
|
||||||
|
currency: CurrencyEnum.CC,
|
||||||
|
price: room.product.corporateCheque.localPrice.numberOfCheques,
|
||||||
|
additionalPrice:
|
||||||
|
room.product.corporateCheque.localPrice.additionalPricePerStay,
|
||||||
|
additionalPriceCurrency:
|
||||||
|
room.product.corporateCheque.localPrice.currency ??
|
||||||
|
CurrencyEnum.Unknown,
|
||||||
|
}
|
||||||
|
} else if ("redemption" in room.product) {
|
||||||
|
rate.roomPrice.currency = CurrencyEnum.POINTS
|
||||||
|
rate.roomPrice.perNight.local = {
|
||||||
|
currency: CurrencyEnum.POINTS,
|
||||||
|
price: room.product.redemption.localPrice.pointsPerNight,
|
||||||
|
additionalPrice:
|
||||||
|
room.product.redemption.localPrice.additionalPricePerStay,
|
||||||
|
additionalPriceCurrency:
|
||||||
|
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
|
||||||
|
}
|
||||||
|
rate.roomPrice.perStay.local = {
|
||||||
|
currency: CurrencyEnum.POINTS,
|
||||||
|
price: room.product.redemption.localPrice.pointsPerStay,
|
||||||
|
additionalPrice:
|
||||||
|
room.product.redemption.localPrice.additionalPricePerStay,
|
||||||
|
additionalPriceCurrency:
|
||||||
|
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
|
||||||
|
}
|
||||||
|
} else if ("voucher" in room.product) {
|
||||||
|
rate.roomPrice.currency = CurrencyEnum.Voucher
|
||||||
|
rate.roomPrice.perNight.local = {
|
||||||
|
currency: CurrencyEnum.Voucher,
|
||||||
|
price: room.product.voucher.numberOfVouchers,
|
||||||
|
}
|
||||||
|
rate.roomPrice.perStay.local = {
|
||||||
|
currency: CurrencyEnum.Voucher,
|
||||||
|
price: room.product.voucher.numberOfVouchers,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currency =
|
||||||
|
room.product.public?.localPrice.currency ||
|
||||||
|
room.product.member?.localPrice.currency ||
|
||||||
|
CurrencyEnum.Unknown
|
||||||
|
rate.roomPrice.currency = currency
|
||||||
|
rate.roomPrice.perNight.local = {
|
||||||
|
currency,
|
||||||
|
price:
|
||||||
|
room.product.public?.localPrice.pricePerNight ||
|
||||||
|
room.product.member?.localPrice.pricePerNight ||
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
rate.roomPrice.perStay.local = {
|
||||||
|
currency,
|
||||||
|
price:
|
||||||
|
room.product.public?.localPrice.pricePerStay ||
|
||||||
|
room.product.member?.localPrice.pricePerStay ||
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rate
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type {
|
||||||
|
Rate,
|
||||||
|
Room as SelectRateRoom,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import type { Room } from "@/components/HotelReservation/PriceDetailsModal/PriceDetailsTable"
|
||||||
|
|
||||||
|
export function mapToPrice(
|
||||||
|
rooms: (Rate | null)[],
|
||||||
|
bookingRooms: SelectRateRoom[],
|
||||||
|
isUserLoggedIn: boolean
|
||||||
|
) {
|
||||||
|
return rooms
|
||||||
|
.map((room, idx) => {
|
||||||
|
if (!room) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let price = null
|
||||||
|
if ("corporateCheque" in room.product) {
|
||||||
|
price = {
|
||||||
|
corporateCheque: room.product.corporateCheque.localPrice,
|
||||||
|
}
|
||||||
|
} else if ("redemption" in room.product) {
|
||||||
|
price = {
|
||||||
|
redemption: room.product.redemption.localPrice,
|
||||||
|
}
|
||||||
|
} else if ("voucher" in room.product) {
|
||||||
|
price = {
|
||||||
|
voucher: room.product.voucher,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isMainRoom = idx === 0
|
||||||
|
const memberRate = room.product.member
|
||||||
|
const onlyMemberRate = !room.product.public && memberRate
|
||||||
|
if ((isUserLoggedIn && isMainRoom && memberRate) || onlyMemberRate) {
|
||||||
|
price = {
|
||||||
|
regular: {
|
||||||
|
...memberRate.localPrice,
|
||||||
|
regularPricePerStay:
|
||||||
|
room.product.public?.localPrice.pricePerStay ||
|
||||||
|
memberRate.localPrice.pricePerStay,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (room.product.public) {
|
||||||
|
price = {
|
||||||
|
regular: room.product.public.localPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingRoom = bookingRooms[idx]
|
||||||
|
return {
|
||||||
|
adults: bookingRoom.adults,
|
||||||
|
bedType: undefined,
|
||||||
|
breakfast: undefined,
|
||||||
|
breakfastIncluded: room.product.rateDefinition.breakfastIncluded,
|
||||||
|
childrenInRoom: bookingRoom.childrenInRoom,
|
||||||
|
packages: room.packages,
|
||||||
|
price,
|
||||||
|
roomType: room.roomType,
|
||||||
|
rateDefinition: room.product.rateDefinition,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((r) => !!(r && r.price)) as Room[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr auto;
|
||||||
|
transition: all 0.5s ease-in-out;
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
align-content: end;
|
||||||
|
z-index: var(--default-modal-z-index);
|
||||||
|
|
||||||
|
&[data-open="true"] {
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
|
||||||
|
.bottomSheet {
|
||||||
|
grid-template-columns: 0fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceDetailsButton {
|
||||||
|
opacity: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-open="false"] .priceDetailsButton {
|
||||||
|
opacity: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signupPromoWrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: var(--default-modal-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--Overlay-40);
|
||||||
|
z-index: var(--default-modal-overlay-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomSheet {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
|
||||||
|
align-items: flex-start;
|
||||||
|
transition: all 0.5s ease-in-out;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceDetailsButton {
|
||||||
|
border-width: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryAccordion {
|
||||||
|
background-color: var(--Main-Grey-White);
|
||||||
|
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-bottom: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceLabel {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
|
||||||
|
&.discounted {
|
||||||
|
color: var(--Text-Accent-Primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.strikeThroughRate {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeDetails {
|
||||||
|
margin-top: var(--Space-x15);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
align-items: center;
|
||||||
|
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.bottomSheet {
|
||||||
|
padding: var(--Space-x2) 0 var(--Space-x7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
.summary {
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
padding: var(--Spacing-x3);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "title button" "date date";
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronIcon {
|
||||||
|
grid-area: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
justify-content: flex-start;
|
||||||
|
grid-area: date;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
margin-top: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addOns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rateDetailsPopover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-half);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x-half);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry > :last-child {
|
||||||
|
justify-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomDivider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalContent {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms {
|
||||||
|
margin-top: var(--Spacing-x3);
|
||||||
|
margin-bottom: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
.termsText:nth-child(n) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
.terms .termsIcon {
|
||||||
|
margin-right: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.bottomDivider {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary .header .chevronButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
|
|
||||||
|
export function getMemberPrice(roomRate: RoomRate) {
|
||||||
|
if ("member" in roomRate && roomRate.member) {
|
||||||
|
return {
|
||||||
|
amount: roomRate.member.localPrice.pricePerStay,
|
||||||
|
currency: roomRate.member.localPrice.currency,
|
||||||
|
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useState, useTransition } from "react"
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { DesktopSummary } from "./DesktopSummary"
|
||||||
|
import { MobileSummary } from "./MobileSummary"
|
||||||
|
|
||||||
|
import styles from "./rateSummary.module.css"
|
||||||
|
|
||||||
|
export function RateSummary() {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
<ErrorBoundary fallback={<div>Unable to render summary</div>}>
|
||||||
|
<InnerRateSummary />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerRateSummary() {
|
||||||
|
const { selectedRates, input } = useSelectRateContext()
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const [_, startTransition] = useTransition()
|
||||||
|
|
||||||
|
if (selectedRates.state === "NONE_SELECTED") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`details?${params}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPriceToShow = selectedRates.totalPrice
|
||||||
|
|
||||||
|
if (
|
||||||
|
!totalPriceToShow ||
|
||||||
|
!selectedRates.rates.some((room) => room?.isSelected ?? false)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// attribute data-footer-spacing used to add spacing
|
||||||
|
// beneath footer to be able to show entire footer upon
|
||||||
|
// scrolling down to the bottom of the page
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-footer-spacing
|
||||||
|
action={`details?${params}`}
|
||||||
|
method="GET"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
<ErrorBoundary fallback={<div>Unable to render desktop summary</div>}>
|
||||||
|
<DesktopSummary
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
input={input}
|
||||||
|
selectedRates={selectedRates}
|
||||||
|
bookingCode={input.data?.booking.bookingCode || ""}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mobileSummary}>
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
|
||||||
|
<MobileSummary />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@keyframes slideUp {
|
||||||
|
0% {
|
||||||
|
bottom: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
bottom: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
align-items: center;
|
||||||
|
animation: slideUp 300ms ease forwards;
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
bottom: -100%;
|
||||||
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPriceContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
padding-top: var(--Spacing-x2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promoContainer {
|
||||||
|
display: none;
|
||||||
|
max-width: 264px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPrice {
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.petInfo {
|
||||||
|
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
|
padding-left: var(--Spacing-x2);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPriceTextDesktop {
|
||||||
|
align-self: center;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continueButton {
|
||||||
|
margin-left: auto;
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPriceTextMobile {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileSummary {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1367px) {
|
||||||
|
.summary {
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petInfo,
|
||||||
|
.promoContainer,
|
||||||
|
.summaryPriceTextDesktop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPriceTextMobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPrice,
|
||||||
|
.continueButton {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPriceContainer {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileSummary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
|
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||||
|
|
||||||
|
import { sumPackages } from "@/components/HotelReservation/utils"
|
||||||
|
|
||||||
|
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
import type {
|
||||||
|
Product,
|
||||||
|
RedemptionProduct,
|
||||||
|
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||||
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
|
import type { Price } from "@/types/components/hotelReservation/price"
|
||||||
|
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
|
||||||
|
export function calculateTotalPrice(
|
||||||
|
selectedRateSummary: Rate[],
|
||||||
|
isUserLoggedIn: boolean
|
||||||
|
) {
|
||||||
|
return selectedRateSummary.reduce<Price>(
|
||||||
|
(total, room, idx) => {
|
||||||
|
if (!("member" in room.product) || !("public" in room.product)) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomNr = idx + 1
|
||||||
|
const isMainRoom = roomNr === 1
|
||||||
|
let rate
|
||||||
|
if (isUserLoggedIn && isMainRoom && room.product.member) {
|
||||||
|
rate = room.product.member
|
||||||
|
} else if (room.product.public) {
|
||||||
|
rate = room.product.public
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rate) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagesPrice = room.packages.reduce(
|
||||||
|
(total, pkg) => {
|
||||||
|
total.local = total.local + pkg.localPrice.totalPrice
|
||||||
|
if (pkg.requestedPrice.totalPrice) {
|
||||||
|
total.requested = total.requested + pkg.requestedPrice.totalPrice
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
},
|
||||||
|
{ local: 0, requested: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
total.local.currency = rate.localPrice.currency
|
||||||
|
total.local.price =
|
||||||
|
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
|
||||||
|
|
||||||
|
if (rate.localPrice.regularPricePerStay) {
|
||||||
|
total.local.regularPrice =
|
||||||
|
(total.local.regularPrice || 0) +
|
||||||
|
rate.localPrice.regularPricePerStay +
|
||||||
|
packagesPrice.local
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate.requestedPrice) {
|
||||||
|
if (!total.requested) {
|
||||||
|
total.requested = {
|
||||||
|
currency: rate.requestedPrice.currency,
|
||||||
|
price: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!total.requested.currency) {
|
||||||
|
total.requested.currency = rate.requestedPrice.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
total.requested.price =
|
||||||
|
total.requested.price +
|
||||||
|
rate.requestedPrice.pricePerStay +
|
||||||
|
packagesPrice.requested
|
||||||
|
|
||||||
|
if (rate.requestedPrice.regularPricePerStay) {
|
||||||
|
total.requested.regularPrice =
|
||||||
|
(total.requested.regularPrice || 0) +
|
||||||
|
rate.requestedPrice.regularPricePerStay +
|
||||||
|
packagesPrice.requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
},
|
||||||
|
{
|
||||||
|
local: {
|
||||||
|
currency: CurrencyEnum.Unknown,
|
||||||
|
price: 0,
|
||||||
|
regularPrice: undefined,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateRedemptionTotalPrice(
|
||||||
|
redemption: RedemptionProduct["redemption"],
|
||||||
|
packages: Packages | null
|
||||||
|
) {
|
||||||
|
const pkgsSum = sumPackages(packages)
|
||||||
|
let additionalPrice
|
||||||
|
if (redemption.localPrice.additionalPricePerStay) {
|
||||||
|
additionalPrice =
|
||||||
|
redemption.localPrice.additionalPricePerStay + pkgsSum.price
|
||||||
|
} else if (pkgsSum.price) {
|
||||||
|
additionalPrice = pkgsSum.price
|
||||||
|
}
|
||||||
|
|
||||||
|
let additionalPriceCurrency
|
||||||
|
if (redemption.localPrice.currency) {
|
||||||
|
additionalPriceCurrency = redemption.localPrice.currency
|
||||||
|
} else if (pkgsSum.currency) {
|
||||||
|
additionalPriceCurrency = pkgsSum.currency
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
local: {
|
||||||
|
additionalPrice,
|
||||||
|
additionalPriceCurrency,
|
||||||
|
currency: CurrencyEnum.POINTS,
|
||||||
|
price: redemption.localPrice.pointsPerStay,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateVoucherPrice(selectedRateSummary: Rate[]) {
|
||||||
|
return selectedRateSummary.reduce<Price>(
|
||||||
|
(total, room) => {
|
||||||
|
if (!("voucher" in room.product)) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
const rate = room.product.voucher
|
||||||
|
|
||||||
|
total.local.price = total.local.price + rate.numberOfVouchers
|
||||||
|
|
||||||
|
const pkgsSum = sumPackages(room.packages)
|
||||||
|
if (pkgsSum.price && pkgsSum.currency) {
|
||||||
|
total.local.additionalPrice =
|
||||||
|
(total.local.additionalPrice || 0) + pkgsSum.price
|
||||||
|
total.local.additionalPriceCurrency = pkgsSum.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
},
|
||||||
|
{
|
||||||
|
local: {
|
||||||
|
currency: CurrencyEnum.Voucher,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
|
||||||
|
return selectedRateSummary.reduce<Price>(
|
||||||
|
(total, room) => {
|
||||||
|
if (!("corporateCheque" in room.product)) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
const rate = room.product.corporateCheque
|
||||||
|
const pkgsSum = sumPackages(room.packages)
|
||||||
|
|
||||||
|
total.local.price = total.local.price + rate.localPrice.numberOfCheques
|
||||||
|
if (rate.localPrice.additionalPricePerStay) {
|
||||||
|
total.local.additionalPrice =
|
||||||
|
(total.local.additionalPrice || 0) +
|
||||||
|
rate.localPrice.additionalPricePerStay +
|
||||||
|
pkgsSum.price
|
||||||
|
} else if (pkgsSum.price) {
|
||||||
|
total.local.additionalPrice =
|
||||||
|
(total.local.additionalPrice || 0) + pkgsSum.price
|
||||||
|
}
|
||||||
|
if (rate.localPrice.currency) {
|
||||||
|
total.local.additionalPriceCurrency = rate.localPrice.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate.requestedPrice) {
|
||||||
|
if (!total.requested) {
|
||||||
|
total.requested = {
|
||||||
|
currency: CurrencyEnum.CC,
|
||||||
|
price: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total.requested.price =
|
||||||
|
total.requested.price + rate.requestedPrice.numberOfCheques
|
||||||
|
|
||||||
|
if (rate.requestedPrice.additionalPricePerStay) {
|
||||||
|
total.requested.additionalPrice =
|
||||||
|
(total.requested.additionalPrice || 0) +
|
||||||
|
rate.requestedPrice.additionalPricePerStay
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate.requestedPrice.currency) {
|
||||||
|
total.requested.additionalPriceCurrency = rate.requestedPrice.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
},
|
||||||
|
{
|
||||||
|
local: {
|
||||||
|
currency: CurrencyEnum.CC,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
requested: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalPrice(
|
||||||
|
mainRoomProduct: Rate | null,
|
||||||
|
rateSummary: Array<Rate | null>,
|
||||||
|
isUserLoggedIn: boolean,
|
||||||
|
intl: IntlShape
|
||||||
|
): Price | null {
|
||||||
|
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
|
||||||
|
|
||||||
|
if (summaryArray.some((rate) => "corporateCheque" in rate.product)) {
|
||||||
|
return calculateCorporateChequePrice(summaryArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainRoomProduct) {
|
||||||
|
return calculateTotalPrice(summaryArray, isUserLoggedIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { packages, product } = mainRoomProduct
|
||||||
|
|
||||||
|
// In case of reward night (redemption) or voucher only single room booking is supported by business rules
|
||||||
|
if ("redemption" in product) {
|
||||||
|
return calculateRedemptionTotalPrice(product.redemption, packages)
|
||||||
|
}
|
||||||
|
if ("voucher" in product) {
|
||||||
|
const voucherPrice = calculateVoucherPrice(summaryArray)
|
||||||
|
voucherPrice.local.currency = intl.formatMessage({
|
||||||
|
defaultMessage: "Voucher",
|
||||||
|
}) as CurrencyEnum
|
||||||
|
return voucherPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateTotalPrice(summaryArray, isUserLoggedIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBookingCodeRate(product: Product | undefined | null) {
|
||||||
|
if (!product) return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
"corporateCheque" in product ||
|
||||||
|
"redemption" in product ||
|
||||||
|
"voucher" in product
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
if (product.public) {
|
||||||
|
return product.public.rateType !== RateTypeEnum.Regular
|
||||||
|
}
|
||||||
|
if (product.member) {
|
||||||
|
return product.member.rateType !== RateTypeEnum.Regular
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
|
import { logger } from "@scandic-hotels/common/logger"
|
||||||
|
import Body from "@scandic-hotels/design-system/Body"
|
||||||
|
import Caption from "@scandic-hotels/design-system/Caption"
|
||||||
|
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 Chip from "@/components/TempDesignSystem/Chip"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||||
|
|
||||||
|
import styles from "./selectedRoomPanel.module.css"
|
||||||
|
|
||||||
|
export function SelectedRoomPanel({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const isMainRoom = roomIndex === 0
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const {
|
||||||
|
selectedRates,
|
||||||
|
actions: { setActiveRoom },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||||
|
const images = selectedRate?.roomInfo?.roomInfo?.images
|
||||||
|
|
||||||
|
const rateTitle = useRateTitle(selectedRate?.rate)
|
||||||
|
|
||||||
|
const selectedProductTitle = useSelectedProductTitle({ roomIndex })
|
||||||
|
|
||||||
|
if (!selectedRate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProductTitle) {
|
||||||
|
logger.error("Selected product is unknown")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const showModifyButton =
|
||||||
|
isMainRoom ||
|
||||||
|
(!isMainRoom && selectedRates.rates.slice(0, roomNr).every((room) => room))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.selectedRoomPanel}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{ roomIndex: roomNr }
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||||
|
{selectedRate.roomInfo.roomType}
|
||||||
|
</Subtitle>
|
||||||
|
<Body color="uiTextMediumContrast">{rateTitle}</Body>
|
||||||
|
<Body color="uiTextHighContrast">{selectedProductTitle}</Body>
|
||||||
|
</div>
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
{images?.[0]?.imageSizes?.tiny ? (
|
||||||
|
<Image
|
||||||
|
alt={
|
||||||
|
selectedRate.roomInfo.roomType ??
|
||||||
|
images[0].metaData?.altText ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
className={styles.img}
|
||||||
|
height={300}
|
||||||
|
src={images[0].imageSizes.tiny}
|
||||||
|
width={600}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showModifyButton && (
|
||||||
|
<div className={styles.modifyButtonContainer}>
|
||||||
|
<Button clean onClick={() => setActiveRoom(roomIndex)}>
|
||||||
|
<Chip size="small" variant="uiTextHighContrast">
|
||||||
|
<MaterialIcon
|
||||||
|
size={16}
|
||||||
|
color="Icon/Inverted"
|
||||||
|
icon="edit_square"
|
||||||
|
/>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Change",
|
||||||
|
})}
|
||||||
|
</Chip>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSelectedProductTitle({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const isUserLoggedIn = useIsUserLoggedIn()
|
||||||
|
const {
|
||||||
|
selectedRates,
|
||||||
|
input: { nights },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||||
|
|
||||||
|
const night = intl.formatMessage({
|
||||||
|
defaultMessage: "night",
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMainRoom = roomIndex === 0
|
||||||
|
|
||||||
|
if (!selectedRate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
|
||||||
|
(pkg) => pkg.localPrice.currency
|
||||||
|
)
|
||||||
|
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
|
||||||
|
(total, pkg) => total + pkg.localPrice.totalPrice,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const selectedPackagesPricePerNight = Math.ceil(
|
||||||
|
selectedPackagesPrice / nights
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isUserLoggedIn &&
|
||||||
|
isMainRoom &&
|
||||||
|
"member" in selectedRate &&
|
||||||
|
selectedRate.member
|
||||||
|
) {
|
||||||
|
const { localPrice } = selectedRate.member
|
||||||
|
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("public" in selectedRate && selectedRate.public) {
|
||||||
|
const { localPrice } = selectedRate.public
|
||||||
|
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("corporateCheque" in selectedRate) {
|
||||||
|
const { localPrice } = selectedRate.corporateCheque
|
||||||
|
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
|
||||||
|
if (
|
||||||
|
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
|
||||||
|
localPrice.currency
|
||||||
|
) {
|
||||||
|
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
|
||||||
|
return `${mainProductTitle} + ${packagesText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("voucher" in selectedRate) {
|
||||||
|
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
|
||||||
|
if (selectedPackagesPrice && selectedPackagesCurrency) {
|
||||||
|
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
|
||||||
|
return `${mainProductText} + ${packagesText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRateTitle(rate: RateEnum | undefined) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const freeCancelation = intl.formatMessage({
|
||||||
|
defaultMessage: "Free cancellation",
|
||||||
|
})
|
||||||
|
const nonRefundable = intl.formatMessage({
|
||||||
|
defaultMessage: "Non-refundable",
|
||||||
|
})
|
||||||
|
const freeBooking = intl.formatMessage({
|
||||||
|
defaultMessage: "Free rebooking",
|
||||||
|
})
|
||||||
|
const payLater = intl.formatMessage({
|
||||||
|
defaultMessage: "Pay later",
|
||||||
|
})
|
||||||
|
const payNow = intl.formatMessage({
|
||||||
|
defaultMessage: "Pay now",
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (rate) {
|
||||||
|
case RateEnum.change:
|
||||||
|
return `${freeBooking}, ${payNow}`
|
||||||
|
case RateEnum.flex:
|
||||||
|
return `${freeCancelation}, ${payLater}`
|
||||||
|
case RateEnum.save:
|
||||||
|
default:
|
||||||
|
return `${nonRefundable}, ${payNow}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.selectedRoomPanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "content image";
|
||||||
|
grid-template-columns: 1fr 190px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
grid-area: content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
border-radius: var(--Corner-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
grid-area: image;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
border-radius: var(--Corner-radius-sm);
|
||||||
|
height: auto;
|
||||||
|
max-height: 105px;
|
||||||
|
object-fit: fill;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifyButtonContainer {
|
||||||
|
bottom: var(--Spacing-x1);
|
||||||
|
position: absolute;
|
||||||
|
right: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.selectedRoomPanel p.subtitle {
|
||||||
|
padding-bottom: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
.selectedRoomPanel {
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
grid-template-areas: "image" "content";
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
.img {
|
||||||
|
max-height: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||||
|
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { SelectedRoomPanel } from "./SelectedRoomPanel"
|
||||||
|
import { roomSelectionPanelVariants } from "./variants"
|
||||||
|
|
||||||
|
import styles from "./multiRoomWrapper.module.css"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
isMultiRoom: boolean
|
||||||
|
roomIndex: number
|
||||||
|
}
|
||||||
|
export function MultiRoomWrapper({ children, isMultiRoom, roomIndex }: Props) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const { getTopOffset } = useStickyPosition()
|
||||||
|
const {
|
||||||
|
activeRoomIndex,
|
||||||
|
selectedRates,
|
||||||
|
actions: { setActiveRoom },
|
||||||
|
input: { data },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const adultCount = data?.booking.rooms[roomIndex]?.adults || 0
|
||||||
|
const childCount = data?.booking.rooms[roomIndex]?.childrenInRoom?.length || 0
|
||||||
|
const isActiveRoom = activeRoomIndex === roomIndex
|
||||||
|
|
||||||
|
const roomMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{ roomIndex: roomNr }
|
||||||
|
)
|
||||||
|
|
||||||
|
const adultsMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
|
||||||
|
},
|
||||||
|
{ adults: adultCount }
|
||||||
|
)
|
||||||
|
|
||||||
|
const childrenMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{children, plural, one {# child} other {# children}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: childCount,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const onlyAdultsMsg = adultsMsg
|
||||||
|
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||||
|
const guestsMsg = childCount ? adultsAndChildrenMsg : onlyAdultsMsg
|
||||||
|
|
||||||
|
const title = [roomMsg, guestsMsg].join(", ")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const SCROLL_OFFSET = 12 + getTopOffset()
|
||||||
|
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
||||||
|
|
||||||
|
// If no room is active we will show all rooms collapsed, hence we want
|
||||||
|
// to scroll to the first room.
|
||||||
|
const selectedRoom =
|
||||||
|
activeRoomIndex === -1 ? roomElements[0] : roomElements[activeRoomIndex]
|
||||||
|
|
||||||
|
if (selectedRoom) {
|
||||||
|
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||||
|
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
||||||
|
|
||||||
|
// Setting a tiny delay for the scrolling. Without it the browser sometimes doesn't scroll up
|
||||||
|
// after modifying the first room.
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}, 5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeRoomIndex])
|
||||||
|
|
||||||
|
const selectedRate = selectedRates.rateSelectedForRoom(roomIndex)
|
||||||
|
|
||||||
|
if (isMultiRoom) {
|
||||||
|
const classNames = roomSelectionPanelVariants({
|
||||||
|
active: isActiveRoom,
|
||||||
|
selected: !!selectedRate && !isActiveRoom,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className={styles.roomContainer} data-multiroom="true">
|
||||||
|
<div className={styles.header}>
|
||||||
|
{selectedRate && !isActiveRoom ? null : (
|
||||||
|
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
|
||||||
|
)}
|
||||||
|
{selectedRate && isActiveRoom ? (
|
||||||
|
<Button
|
||||||
|
intent="text"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveRoom("deselect")
|
||||||
|
}}
|
||||||
|
size="medium"
|
||||||
|
theme="base"
|
||||||
|
variant="icon"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Close",
|
||||||
|
})}
|
||||||
|
<MaterialIcon
|
||||||
|
icon="keyboard_arrow_up"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={classNames}>
|
||||||
|
<div className={styles.roomPanel}>
|
||||||
|
<SelectedRoomPanel roomIndex={roomIndex} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.roomContainer {
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomPanel,
|
||||||
|
.roomSelectionPanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
height: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
grid-template-rows 0.3s ease;
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomPanel > * {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomSelectionPanel {
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomSelectionPanelContainer.active .roomSelectionPanel,
|
||||||
|
.roomSelectionPanelContainer.selected .roomPanel {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomSelectionPanelContainer.active .roomPanel {
|
||||||
|
padding-top: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.roomContainer {
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
import styles from "./multiRoomWrapper.module.css"
|
||||||
|
|
||||||
|
export const roomSelectionPanelVariants = cva(
|
||||||
|
styles.roomSelectionPanelContainer,
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
true: styles.active,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
true: styles.selected,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
active: false,
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.hotelAlert {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--Spacing-x-one-and-half);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||||
|
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||||
|
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
||||||
|
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import styles from "./alert.module.css"
|
||||||
|
|
||||||
|
export default function NoAvailabilityAlert({
|
||||||
|
roomIndex,
|
||||||
|
}: {
|
||||||
|
roomIndex: number
|
||||||
|
}) {
|
||||||
|
const lang = useLang()
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const { availability, input } = useSelectRateContext()
|
||||||
|
if (availability.isFetching || !availability.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexed = availability.data[roomIndex]
|
||||||
|
const hasAvailabilityError = "error" in indexed
|
||||||
|
if (hasAvailabilityError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const noAvailableRooms = hasAvailableRoomsForRoom(indexed.roomConfigurations)
|
||||||
|
|
||||||
|
const alertLink =
|
||||||
|
roomIndex !== -1 &&
|
||||||
|
(input.data?.booking.rooms.at(roomIndex)?.packages ?? []).length === 0
|
||||||
|
? {
|
||||||
|
title: intl.formatMessage({
|
||||||
|
defaultMessage: "See alternative hotels",
|
||||||
|
}),
|
||||||
|
url: `${alternativeHotels(lang)}`,
|
||||||
|
keepSearchParams: true,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (noAvailableRooms) {
|
||||||
|
const text = intl.formatMessage({
|
||||||
|
defaultMessage: "There are no rooms available that match your request.",
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className={styles.hotelAlert}>
|
||||||
|
<Alert
|
||||||
|
type={AlertTypeEnum.Info}
|
||||||
|
heading={intl.formatMessage({
|
||||||
|
defaultMessage: "No availability",
|
||||||
|
})}
|
||||||
|
text={text}
|
||||||
|
link={alertLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublicPromotionWithCode = indexed.roomConfigurations.some((room) => {
|
||||||
|
const filteredCampaigns = room.campaign.filter(Boolean)
|
||||||
|
return filteredCampaigns.length
|
||||||
|
? filteredCampaigns.every(
|
||||||
|
(product) => !!product.rateDefinition?.isCampaignRate
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const noAvailableBookingCodeRooms =
|
||||||
|
!isPublicPromotionWithCode &&
|
||||||
|
indexed.roomConfigurations.every(
|
||||||
|
(room) =>
|
||||||
|
room.status === AvailabilityEnum.NotAvailable || !room.code.length
|
||||||
|
)
|
||||||
|
|
||||||
|
if (input.bookingCode && noAvailableBookingCodeRooms) {
|
||||||
|
const bookingCodeText = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||||
|
},
|
||||||
|
{ bookingCode: input.bookingCode }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.hotelAlert}>
|
||||||
|
<Alert
|
||||||
|
type={AlertTypeEnum.Info}
|
||||||
|
heading={intl.formatMessage({
|
||||||
|
defaultMessage: "No availability",
|
||||||
|
})}
|
||||||
|
text={bookingCodeText}
|
||||||
|
link={alertLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAvailableRoomsForRoom(
|
||||||
|
roomConfigurations: Extract<
|
||||||
|
NonNullable<
|
||||||
|
ReturnType<typeof useSelectRateContext>["availability"]["data"]
|
||||||
|
>[number],
|
||||||
|
{ roomConfigurations: unknown }
|
||||||
|
>["roomConfigurations"]
|
||||||
|
) {
|
||||||
|
return roomConfigurations.every(
|
||||||
|
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.bookingCodeFilter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
background-color: var(--Surface-Primary-Default);
|
||||||
|
box-shadow: var(--popup-box-shadow);
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
padding: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio[data-hovered] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.radio[data-focus-visible]::before {
|
||||||
|
outline: 1px auto var(--Border-Interactive-Focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio::before {
|
||||||
|
flex-shrink: 0;
|
||||||
|
content: "";
|
||||||
|
margin-right: var(--Space-x15);
|
||||||
|
background-color: var(--Surface-UI-Fill-Default);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio[data-selected]::before {
|
||||||
|
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--Overlay-40);
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: overlay-fade 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-exiting] {
|
||||||
|
animation: overlay-fade 150ms reverse ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--Space-x2) var(--Space-x05);
|
||||||
|
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||||
|
background-color: var(--Surface-Primary-Default);
|
||||||
|
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: modal-anim 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-exiting] {
|
||||||
|
animation: modal-anim 150ms reverse ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDialog {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
padding: 0 var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.radioGroup {
|
||||||
|
padding: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOverlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlay-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-anim {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
Popover,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
} from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||||
|
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 { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import { useBreakpoint } from "@/hooks/useBreakpoint"
|
||||||
|
|
||||||
|
import styles from "./bookingCodeFilter.module.css"
|
||||||
|
|
||||||
|
export function BookingCodeFilter({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const displayAsModal = useBreakpoint("mobile")
|
||||||
|
|
||||||
|
const {
|
||||||
|
input,
|
||||||
|
getAvailabilityForRoom,
|
||||||
|
bookingCodeFilter,
|
||||||
|
actions: { selectBookingCodeFilter },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
const roomAvailability = getAvailabilityForRoom(roomIndex)
|
||||||
|
|
||||||
|
const bookingCodeFilterItems = [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
defaultMessage: "Booking code rates",
|
||||||
|
}),
|
||||||
|
value: BookingCodeFilterEnum.Discounted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
defaultMessage: "All rates",
|
||||||
|
}),
|
||||||
|
value: BookingCodeFilterEnum.All,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function updateFilterValue(selectedFilter: string) {
|
||||||
|
selectBookingCodeFilter(selectedFilter as BookingCodeFilterEnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideFilter = (roomAvailability ?? []).some((room) => {
|
||||||
|
room.products.some((product) => {
|
||||||
|
const isRedemption = Array.isArray(product)
|
||||||
|
if (isRedemption) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (product.rateDefinition.rateType) {
|
||||||
|
case RateTypeEnum.Arb:
|
||||||
|
case RateTypeEnum.CorporateCheque:
|
||||||
|
case RateTypeEnum.Voucher:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hideFilter || !input?.bookingCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.bookingCodeFilter}>
|
||||||
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<ChipButton variant="Outlined">
|
||||||
|
{
|
||||||
|
bookingCodeFilterItems.find(
|
||||||
|
(item) => item.value === bookingCodeFilter
|
||||||
|
)?.label
|
||||||
|
}
|
||||||
|
<MaterialIcon
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</ChipButton>
|
||||||
|
{!displayAsModal ? (
|
||||||
|
<Popover placement="bottom end" isNonModal>
|
||||||
|
<Dialog className={styles.dialog}>
|
||||||
|
{({ close }) => {
|
||||||
|
function handleChangeFilterValue(value: string) {
|
||||||
|
updateFilterValue(value)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<RadioGroup
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
defaultMessage: "Booking Code Filter",
|
||||||
|
})}
|
||||||
|
onChange={handleChangeFilterValue}
|
||||||
|
name="bookingCodeFilter"
|
||||||
|
value={bookingCodeFilter}
|
||||||
|
className={styles.radioGroup}
|
||||||
|
>
|
||||||
|
{bookingCodeFilterItems.map((item) => (
|
||||||
|
<Radio
|
||||||
|
aria-label={item.label}
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
className={styles.radio}
|
||||||
|
autoFocus={bookingCodeFilter === item.value}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||||
|
<Modal className={styles.modal}>
|
||||||
|
<Dialog className={styles.modalDialog}>
|
||||||
|
{({ close }) => {
|
||||||
|
function handleChangeFilterValue(value: string) {
|
||||||
|
updateFilterValue(value)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<h3>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Room rates",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
theme="Black"
|
||||||
|
style="Muted"
|
||||||
|
onPress={() => {
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="close"
|
||||||
|
size={24}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<RadioGroup
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
defaultMessage: "Booking Code Filter",
|
||||||
|
})}
|
||||||
|
onChange={handleChangeFilterValue}
|
||||||
|
name="bookingCodeFilter"
|
||||||
|
value={bookingCodeFilter}
|
||||||
|
className={styles.radioGroup}
|
||||||
|
>
|
||||||
|
{bookingCodeFilterItems.map((item) => (
|
||||||
|
<Radio
|
||||||
|
aria-label={item.label}
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
className={styles.radio}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
import BookingCodeChip from "@/components/BookingCodeChip"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
export function RemoveBookingCodeButton() {
|
||||||
|
const {
|
||||||
|
input: { bookingCode },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
if (!bookingCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BookingCodeChip
|
||||||
|
bookingCode={bookingCode}
|
||||||
|
filledIcon
|
||||||
|
withCloseButton={true}
|
||||||
|
withText={false}
|
||||||
|
onClose={() => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams)
|
||||||
|
newSearchParams.delete("bookingCode")
|
||||||
|
|
||||||
|
const url = `${pathname}?${newSearchParams.toString()}`
|
||||||
|
|
||||||
|
router.replace(url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import styles from "./petRoom.module.css"
|
||||||
|
|
||||||
|
export default function PetRoomMessage({
|
||||||
|
priceData,
|
||||||
|
}: {
|
||||||
|
priceData?: { price: number; currency: string }
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!priceData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<p className={styles.additionalInformation}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b: (str) => (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<span className={styles.additionalInformationPrice}>{str}</span>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
price: formatPrice(intl, priceData.price, priceData.currency),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.additionalInformation {
|
||||||
|
color: var(--Text-Tertiary);
|
||||||
|
padding: var(--Space-x1) var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.additionalInformationPrice {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
.checkboxGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxWrapper {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxField {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x15);
|
||||||
|
padding: var(--Space-x1) var(--Space-x15);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
color: var(--Text-Default);
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
cursor: unset;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
border-color: var(--Border-Interactive-Disabled);
|
||||||
|
background-color: var(--Surface-UI-Fill-Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: var(--Base-Text-Disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not([data-disabled]) {
|
||||||
|
background-color: var(--UI-Input-Controls-Surface-Hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focus-visible] .checkbox {
|
||||||
|
/* Used this value as it makes sense from a token name perspective and has a good contrast, but we need to decide for a default ui state */
|
||||||
|
outline: 2px solid var(--Border-Interactive-Focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-selected] .checkbox {
|
||||||
|
border-color: var(--Surface-UI-Fill-Active);
|
||||||
|
background-color: var(--Surface-UI-Fill-Active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
border: 1px solid var(--Border-Interactive-Default);
|
||||||
|
border-radius: var(--Corner-radius-sm);
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--Surface-UI-Fill-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
.checkboxField:hover:not([data-disabled]) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxField[data-selected] {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
import { Checkbox, CheckboxGroup } from "react-aria-components"
|
||||||
|
import { Controller, useFormContext } from "react-hook-form"
|
||||||
|
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
|
import { usePackageLabels } from "../../usePackageLabels"
|
||||||
|
import { getIconNameByPackageCode } from "../../utils"
|
||||||
|
|
||||||
|
import styles from "./checkbox.module.css"
|
||||||
|
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import type { FormValues } from "../formValues"
|
||||||
|
|
||||||
|
export function PackageCheckboxes({
|
||||||
|
availablePackages,
|
||||||
|
}: {
|
||||||
|
availablePackages: {
|
||||||
|
code: RoomPackageCodeEnum
|
||||||
|
message?: ReactNode
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const { control } = useFormContext<FormValues>()
|
||||||
|
const packageLabels = usePackageLabels()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="selectedPackages"
|
||||||
|
render={({ field }) => {
|
||||||
|
const allergyRoomSelected = includesAllergyRoom(field.value)
|
||||||
|
const petRoomSelected = includesPetRoom(field.value)
|
||||||
|
return (
|
||||||
|
<CheckboxGroup {...field} className={styles.checkboxGroup}>
|
||||||
|
{availablePackages?.map((option) => {
|
||||||
|
const isAllergyRoom = checkIsAllergyRoom(option.code)
|
||||||
|
const isPetRoom = checkIsPetRoom(option.code)
|
||||||
|
const isDisabled =
|
||||||
|
(isPetRoom && allergyRoomSelected) ||
|
||||||
|
(isAllergyRoom && petRoomSelected)
|
||||||
|
|
||||||
|
const isSelected = field.value.includes(option.code)
|
||||||
|
const iconName = getIconNameByPackageCode(option.code)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={option.code} className={styles.checkboxWrapper}>
|
||||||
|
<Checkbox
|
||||||
|
key={option.code}
|
||||||
|
className={styles.checkboxField}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
value={option.code}
|
||||||
|
>
|
||||||
|
<span className={styles.checkbox}>
|
||||||
|
{isSelected ? (
|
||||||
|
<MaterialIcon icon="check" color="Icon/Inverted" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Typography
|
||||||
|
className={styles.text}
|
||||||
|
variant="Body/Paragraph/mdRegular"
|
||||||
|
>
|
||||||
|
<span>{packageLabels[option.code]}</span>
|
||||||
|
</Typography>
|
||||||
|
{iconName ? (
|
||||||
|
<MaterialIcon icon={iconName} color="Icon/Default" />
|
||||||
|
) : null}
|
||||||
|
</Checkbox>
|
||||||
|
{option.message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CheckboxGroup>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function includesAllergyRoom(codes: PackageEnum[]) {
|
||||||
|
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function includesPetRoom(codes: PackageEnum[]) {
|
||||||
|
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIsAllergyRoom(
|
||||||
|
code: PackageEnum
|
||||||
|
): code is RoomPackageCodeEnum.ALLERGY_ROOM {
|
||||||
|
return code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIsPetRoom(
|
||||||
|
code: PackageEnum
|
||||||
|
): code is RoomPackageCodeEnum.PET_ROOM {
|
||||||
|
return code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.footer {
|
||||||
|
padding: 0 var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin: var(--Space-x15) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
.divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: var(--Space-x5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.buttonContainer {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
export type FormValues = {
|
||||||
|
selectedPackages: PackageEnum[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { PackageCheckboxes } from "./Checkboxes"
|
||||||
|
|
||||||
|
import styles from "./form.module.css"
|
||||||
|
|
||||||
|
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import type { FormValues } from "./formValues"
|
||||||
|
|
||||||
|
export function RoomPackagesForm({
|
||||||
|
close,
|
||||||
|
selectedPackages,
|
||||||
|
onSelectPackages,
|
||||||
|
availablePackages,
|
||||||
|
}: {
|
||||||
|
close: () => void
|
||||||
|
availablePackages: {
|
||||||
|
code: RoomPackageCodeEnum
|
||||||
|
message: ReactNode
|
||||||
|
}[]
|
||||||
|
selectedPackages: PackageEnum[]
|
||||||
|
onSelectPackages: (packages: PackageEnum[]) => void
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const methods = useForm<FormValues>({
|
||||||
|
values: {
|
||||||
|
selectedPackages: selectedPackages,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearSelectedPackages() {
|
||||||
|
onSelectPackages([])
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(data: FormValues) {
|
||||||
|
onSelectPackages(data.selectedPackages)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||||
|
<PackageCheckboxes availablePackages={availablePackages} />
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Divider color="Border/Divider/Subtle" className={styles.divider} />
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<Button variant="Tertiary" size="Small" type="submit">
|
||||||
|
{intl.formatMessage({ defaultMessage: "Apply" })}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<Button
|
||||||
|
onPress={clearSelectedPackages}
|
||||||
|
size="Small"
|
||||||
|
variant="Text"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Clear",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { type ReactNode, useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
} from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||||
|
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 { RoomPackagesForm } from "./Form"
|
||||||
|
|
||||||
|
import styles from "./roomPackageFilter.module.css"
|
||||||
|
|
||||||
|
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
export function RoomPackageFilterModal({
|
||||||
|
selectedPackages,
|
||||||
|
onSelectPackages,
|
||||||
|
availablePackages,
|
||||||
|
}: {
|
||||||
|
onSelectPackages: (packages: PackageEnum[]) => void
|
||||||
|
selectedPackages: PackageEnum[]
|
||||||
|
availablePackages: {
|
||||||
|
code: RoomPackageCodeEnum
|
||||||
|
message: ReactNode
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<ChipButton variant="Outlined">
|
||||||
|
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||||
|
<MaterialIcon
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</ChipButton>
|
||||||
|
|
||||||
|
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||||
|
<Modal className={styles.modal}>
|
||||||
|
<Dialog className={styles.modalDialog}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<h3>
|
||||||
|
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||||
|
</h3>
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
theme="Black"
|
||||||
|
style="Muted"
|
||||||
|
onPress={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="close" size={24} color="CurrentColor" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<RoomPackagesForm
|
||||||
|
close={() => setIsOpen(false)}
|
||||||
|
availablePackages={availablePackages}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
onSelectPackages={onSelectPackages}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
</DialogTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode, useState } from "react"
|
||||||
|
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import { RoomPackagesForm } from "./Form"
|
||||||
|
|
||||||
|
import styles from "./roomPackageFilter.module.css"
|
||||||
|
|
||||||
|
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
export function RoomPackageFilterPopover({
|
||||||
|
selectedPackages,
|
||||||
|
onSelectPackages,
|
||||||
|
availablePackages,
|
||||||
|
}: {
|
||||||
|
onSelectPackages: (packages: PackageEnum[]) => void
|
||||||
|
selectedPackages: PackageEnum[]
|
||||||
|
availablePackages: {
|
||||||
|
code: RoomPackageCodeEnum
|
||||||
|
message: ReactNode
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<ChipButton variant="Outlined">
|
||||||
|
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||||
|
<MaterialIcon
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size={20}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
</ChipButton>
|
||||||
|
|
||||||
|
<Popover placement="bottom end" className={styles.popover}>
|
||||||
|
<Dialog>
|
||||||
|
<RoomPackagesForm
|
||||||
|
close={() => setIsOpen(false)}
|
||||||
|
availablePackages={availablePackages}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
onSelectPackages={onSelectPackages}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
</DialogTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
import { Button as ButtonRAC } from "react-aria-components"
|
||||||
|
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import { useBreakpoint } from "@/hooks/useBreakpoint"
|
||||||
|
|
||||||
|
import PetRoomMessage from "./Form/Checkboxes/PetRoomMessage"
|
||||||
|
import { RoomPackageFilterModal } from "./Modal"
|
||||||
|
import { RoomPackageFilterPopover } from "./Popover"
|
||||||
|
import { usePackageLabels } from "./usePackageLabels"
|
||||||
|
import { getIconNameByPackageCode } from "./utils"
|
||||||
|
|
||||||
|
import styles from "./roomPackageFilter.module.css"
|
||||||
|
|
||||||
|
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
export function RoomPackageFilter({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const displayAsModal = useBreakpoint("mobile")
|
||||||
|
|
||||||
|
const {
|
||||||
|
getPackagesForRoom,
|
||||||
|
actions: { selectPackages },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
const { selectedPackages, availablePackages } = getPackagesForRoom(roomIndex)
|
||||||
|
|
||||||
|
function deletePackage(code: PackageEnum) {
|
||||||
|
selectPackages({
|
||||||
|
roomIndex,
|
||||||
|
packages: selectedPackages
|
||||||
|
.filter((pkg) => pkg.code !== code)
|
||||||
|
.map((pkg) => pkg.code),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const petRoomPackage = availablePackages.find(
|
||||||
|
(x) => x.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
)
|
||||||
|
const packageLabels = usePackageLabels()
|
||||||
|
const packageMessages = packageMessageMap({
|
||||||
|
petRoomPrice:
|
||||||
|
petRoomPackage && !("type" in petRoomPackage)
|
||||||
|
? petRoomPackage.localPrice
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const packages = availablePackages
|
||||||
|
.map((x) => {
|
||||||
|
if (!isRoomPackage(x)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: x.code,
|
||||||
|
message: packageMessages[x.code],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((x) => {
|
||||||
|
return !!x
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.roomPackageFilter}>
|
||||||
|
<div className={styles.selectedPackages}>
|
||||||
|
{selectedPackages.map((pkg) => (
|
||||||
|
<Typography
|
||||||
|
key={pkg.code}
|
||||||
|
variant="Body/Supporting text (caption)/smRegular"
|
||||||
|
>
|
||||||
|
<span className={styles.selectedPackage}>
|
||||||
|
<MaterialIcon
|
||||||
|
icon={getIconNameByPackageCode(pkg.code)}
|
||||||
|
size={16}
|
||||||
|
color="CurrentColor"
|
||||||
|
/>
|
||||||
|
{packageLabels[pkg.code] ?? pkg.description}
|
||||||
|
<ButtonRAC
|
||||||
|
onPress={() => deletePackage(pkg.code)}
|
||||||
|
className={styles.removeButton}
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="close" size={16} color="CurrentColor" />
|
||||||
|
</ButtonRAC>
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{displayAsModal ? (
|
||||||
|
<div>
|
||||||
|
<RoomPackageFilterModal
|
||||||
|
availablePackages={packages}
|
||||||
|
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||||
|
onSelectPackages={(packages) => {
|
||||||
|
selectPackages({ roomIndex, packages })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<RoomPackageFilterPopover
|
||||||
|
availablePackages={packages}
|
||||||
|
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||||
|
onSelectPackages={(packages) => {
|
||||||
|
selectPackages({ roomIndex, packages })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoomPackage(x: {
|
||||||
|
code: BreakfastPackageEnum | RoomPackageCodeEnum
|
||||||
|
}): x is { code: RoomPackageCodeEnum } {
|
||||||
|
return Object.values(RoomPackageCodeEnum).includes(
|
||||||
|
x.code as RoomPackageCodeEnum
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageMessageMap = ({
|
||||||
|
petRoomPrice,
|
||||||
|
}: {
|
||||||
|
petRoomPrice?: { price: number; currency: string }
|
||||||
|
}): Record<RoomPackageCodeEnum, ReactNode | undefined> => ({
|
||||||
|
[RoomPackageCodeEnum.PET_ROOM]: <PetRoomMessage priceData={petRoomPrice} />,
|
||||||
|
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: undefined,
|
||||||
|
[RoomPackageCodeEnum.ALLERGY_ROOM]: undefined,
|
||||||
|
})
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.roomPackageFilter {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedPackages {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--Overlay-40);
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: overlay-fade 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-exiting] {
|
||||||
|
animation: overlay-fade 150ms reverse ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--Space-x2) var(--Space-x05);
|
||||||
|
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||||
|
background-color: var(--Surface-Primary-Default);
|
||||||
|
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: modal-anim 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-exiting] {
|
||||||
|
animation: modal-anim 150ms reverse ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDialog {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
padding: 0 var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedPackage {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--Space-x1);
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
border-radius: var(--Corner-radius-sm);
|
||||||
|
background-color: var(--Surface-Secondary-Default-dark);
|
||||||
|
color: var(--Text-Interactive-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeButton {
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--Space-x05);
|
||||||
|
margin: calc(-1 * var(--Space-x05));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
.popover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.roomPackageFilter {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOverlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
padding: var(--Space-x2);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
background-color: var(--Surface-Primary-Default);
|
||||||
|
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 340px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxContainer {
|
||||||
|
padding: 0 var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlay-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-anim {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
|
export const usePackageLabels = () => {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const labels: Record<RoomPackageCodeEnum, string> = {
|
||||||
|
[RoomPackageCodeEnum.ALLERGY_ROOM]: intl.formatMessage({
|
||||||
|
defaultMessage: "Allergy-friendly room",
|
||||||
|
}),
|
||||||
|
[RoomPackageCodeEnum.PET_ROOM]: intl.formatMessage({
|
||||||
|
defaultMessage: "Pet-friendly room",
|
||||||
|
}),
|
||||||
|
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: intl.formatMessage({
|
||||||
|
defaultMessage: "Accessible room",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
|
import type { MaterialSymbolProps } from "@scandic-hotels/design-system/Icons/MaterialIcon/MaterialSymbol"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
export function getIconNameByPackageCode(
|
||||||
|
packageCode: PackageEnum
|
||||||
|
): MaterialSymbolProps["icon"] {
|
||||||
|
switch (packageCode) {
|
||||||
|
case RoomPackageCodeEnum.PET_ROOM:
|
||||||
|
return "pets"
|
||||||
|
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
|
||||||
|
return "accessible"
|
||||||
|
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
||||||
|
return "mode_fan"
|
||||||
|
default:
|
||||||
|
return "star"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||||
|
import { RoomPackageFilter } from "./RoomPackageFilter"
|
||||||
|
|
||||||
|
import styles from "./roomsHeader.module.css"
|
||||||
|
|
||||||
|
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
|
||||||
|
<InnerRoomsHeader roomIndex={roomIndex} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerRoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<AvailableRoomCount roomIndex={roomIndex} />
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<RemoveBookingCodeButton />
|
||||||
|
<RoomPackageFilter roomIndex={roomIndex} />
|
||||||
|
{/* <BookingCodeFilter roomIndex={roomIndex} /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvailableRoomCount({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { isFetching, getAvailabilityForRoom } = useSelectRateContext()
|
||||||
|
|
||||||
|
const roomAvailability = getAvailabilityForRoom(roomIndex) ?? []
|
||||||
|
|
||||||
|
const availableRooms = roomAvailability.filter(
|
||||||
|
(x) => x.status === AvailabilityEnum.Available
|
||||||
|
).length
|
||||||
|
|
||||||
|
const totalRooms = roomAvailability.length
|
||||||
|
|
||||||
|
const notAllRoomsAvailableText = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availableRooms,
|
||||||
|
numberOfRooms: totalRooms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const allRoomsAvailableText = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numberOfRooms: totalRooms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <SkeletonShimmer height="30px" width="25ch" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
|
||||||
|
<p>
|
||||||
|
{availableRooms !== totalRooms
|
||||||
|
? notAllRoomsAvailableText
|
||||||
|
: allRoomsAvailableText}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availableRooms {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||||
|
|
||||||
|
export default function RoomSize({ roomSize }: RoomSizeProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!roomSize) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomSize.min === roomSize.max) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
<p>∙</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<h4>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{roomSize} m²",
|
||||||
|
},
|
||||||
|
{ roomSize: roomSize.min }
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
<p>∙</p>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<h4>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{roomSizeMin} - {roomSizeMax} m²",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roomSizeMin: roomSize.min,
|
||||||
|
roomSizeMax: roomSize.max,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import useSidePeekStore from "@/stores/sidepeek"
|
||||||
|
|
||||||
|
import styles from "./details.module.css"
|
||||||
|
|
||||||
|
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||||
|
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||||
|
|
||||||
|
export default function ToggleSidePeek({
|
||||||
|
hotelId,
|
||||||
|
roomTypeCode,
|
||||||
|
}: ToggleSidePeekProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
openSidePeek({
|
||||||
|
key: SidePeekEnum.roomDetails,
|
||||||
|
hotelId,
|
||||||
|
roomTypeCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="Small"
|
||||||
|
variant="Text"
|
||||||
|
wrapping
|
||||||
|
typography="Body/Supporting text (caption)/smBold"
|
||||||
|
color="Inverted"
|
||||||
|
className={styles.sidePeekButton}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ defaultMessage: "View room details" })}
|
||||||
|
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.specification {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
padding-bottom: var(--Space-x05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidePeekButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import RoomSize from "./RoomSize"
|
||||||
|
|
||||||
|
import styles from "./details.module.css"
|
||||||
|
|
||||||
|
import type { RoomInfo } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
roomInfo: RoomInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Details({ roomInfo }: Props) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const { name, occupancy, roomSize } = roomInfo || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.specification}>
|
||||||
|
{occupancy && (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<h4>
|
||||||
|
{occupancy.max === occupancy.min
|
||||||
|
? intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{guests, plural, one {# guest} other {# guests}}",
|
||||||
|
},
|
||||||
|
{ guests: occupancy.max }
|
||||||
|
)
|
||||||
|
: intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{min}-{max} guests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: occupancy.min,
|
||||||
|
max: occupancy.max,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<RoomSize roomSize={roomSize} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.roomDetails}>
|
||||||
|
<Typography variant="Title/Subtitle/lg">
|
||||||
|
<h2>{name}</h2>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakfastMessage {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 5%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HotelTypeEnum } from "@scandic-hotels/trpc/enums/hotelType"
|
||||||
|
|
||||||
|
export function getBreakfastMessage(
|
||||||
|
publicBreakfastIncluded: boolean,
|
||||||
|
memberBreakfastIncluded: boolean,
|
||||||
|
hotelType: string | undefined,
|
||||||
|
userIsLoggedIn: boolean,
|
||||||
|
msgs: Record<
|
||||||
|
"included" | "noSelection" | "scandicgo" | "notIncluded",
|
||||||
|
string
|
||||||
|
>,
|
||||||
|
roomNr: number
|
||||||
|
) {
|
||||||
|
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||||
|
return msgs.scandicgo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
|
||||||
|
return msgs.included
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||||
|
return msgs.included
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||||
|
return msgs.notIncluded
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs.notIncluded
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
|
import { getBreakfastMessage } from "./getBreakfastMessage"
|
||||||
|
|
||||||
|
import styles from "./breakfastMessage.module.css"
|
||||||
|
|
||||||
|
export function BreakfastMessage({
|
||||||
|
breakfastIncludedMember,
|
||||||
|
breakfastIncludedStandard,
|
||||||
|
hasRegularRates,
|
||||||
|
roomIndex,
|
||||||
|
}: {
|
||||||
|
breakfastIncludedMember: boolean
|
||||||
|
breakfastIncludedStandard: boolean
|
||||||
|
hasRegularRates: boolean
|
||||||
|
roomIndex: number
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { hotel } = useSelectRateContext()
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
|
||||||
|
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||||
|
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||||
|
const hotelType = hotel.data?.hotel.hotelType
|
||||||
|
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
|
|
||||||
|
const breakfastMessages = {
|
||||||
|
included: intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast is included.",
|
||||||
|
}),
|
||||||
|
notIncluded: intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast excluded, add in next step.",
|
||||||
|
}),
|
||||||
|
noSelection: intl.formatMessage({
|
||||||
|
defaultMessage: "Select a rate",
|
||||||
|
}),
|
||||||
|
scandicgo: intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast deal can be purchased at the hotel.",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakfastMessage = getBreakfastMessage(
|
||||||
|
breakfastIncludedStandard,
|
||||||
|
breakfastIncludedMember,
|
||||||
|
hotelType,
|
||||||
|
isUserLoggedIn,
|
||||||
|
breakfastMessages,
|
||||||
|
roomNr
|
||||||
|
)
|
||||||
|
|
||||||
|
const isDiscount = selectedFilter === BookingCodeFilterEnum.Discounted
|
||||||
|
|
||||||
|
if (isDiscount || !hasRegularRates) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.message}>
|
||||||
|
<Divider className={styles.divider} color="Border/Divider/Subtle" />
|
||||||
|
<Typography
|
||||||
|
variant={"Body/Supporting text (caption)/smRegular"}
|
||||||
|
className={styles.breakfastMessage}
|
||||||
|
>
|
||||||
|
<p>{breakfastMessage}</p>
|
||||||
|
</Typography>
|
||||||
|
<Divider className={styles.divider} color="Border/Divider/Subtle" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import CampaignRateCard from "@scandic-hotels/design-system/CampaignRateCard"
|
||||||
|
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||||
|
|
||||||
|
import {
|
||||||
|
sumPackages,
|
||||||
|
sumPackagesRequestedPrice,
|
||||||
|
} from "@/components/HotelReservation/utils"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||||
|
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||||
|
|
||||||
|
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AvailabilityWithRoomInfo,
|
||||||
|
Package,
|
||||||
|
} from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
type CampaignProps = {
|
||||||
|
nights: number
|
||||||
|
campaign: AvailabilityWithRoomInfo["campaign"]
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Campaign({
|
||||||
|
campaign,
|
||||||
|
roomIndex,
|
||||||
|
nights,
|
||||||
|
roomTypeCode,
|
||||||
|
selectedPackages,
|
||||||
|
}: CampaignProps) {
|
||||||
|
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||||
|
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||||
|
|
||||||
|
const isCampaignRate = campaign.some(
|
||||||
|
(c) =>
|
||||||
|
c.rateDefinition.isCampaignRate || c.rateDefinitionMember?.isCampaignRate
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectedFilter === BookingCodeFilterEnum.Discounted && !isCampaignRate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||||
|
campaign = campaign.filter((product) => product.bookingCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaign.map((product, ix) => {
|
||||||
|
return (
|
||||||
|
<Inner
|
||||||
|
key={ix}
|
||||||
|
product={product}
|
||||||
|
nights={nights}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({
|
||||||
|
product,
|
||||||
|
roomIndex,
|
||||||
|
roomTypeCode,
|
||||||
|
selectedPackages,
|
||||||
|
nights,
|
||||||
|
}: {
|
||||||
|
roomIndex: number
|
||||||
|
nights: number
|
||||||
|
roomTypeCode: string
|
||||||
|
product: AvailabilityWithRoomInfo["campaign"][number]
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}) {
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const {
|
||||||
|
isRateSelected,
|
||||||
|
actions: { selectRate },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
const isUserLoggedIn = useIsUserLoggedIn()
|
||||||
|
const intl = useIntl()
|
||||||
|
const night = intl
|
||||||
|
.formatMessage({
|
||||||
|
defaultMessage: "night",
|
||||||
|
})
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
const standardPriceMsg = intl.formatMessage({
|
||||||
|
defaultMessage: "Standard price",
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberPriceMsg = intl.formatMessage({
|
||||||
|
defaultMessage: "Member price",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product.public) {
|
||||||
|
return (
|
||||||
|
<NoRateAvailableCard
|
||||||
|
key={product.rate}
|
||||||
|
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||||
|
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||||
|
rateTitle={rateTitles[product.rate].title}
|
||||||
|
variant="Campaign"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateTermDetails = product.rateDefinitionMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: product.bookingCode
|
||||||
|
? product.rateDefinition.title
|
||||||
|
: standardPriceMsg,
|
||||||
|
terms: product.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: product.bookingCode
|
||||||
|
? product.rateDefinitionMember.title
|
||||||
|
: memberPriceMsg,
|
||||||
|
terms: product.rateDefinitionMember.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: product.bookingCode
|
||||||
|
? product.rateDefinition.title
|
||||||
|
: standardPriceMsg,
|
||||||
|
terms: product.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const isSelected = isRateSelected({
|
||||||
|
roomIndex,
|
||||||
|
rate: { ...product, type: "campaign" },
|
||||||
|
roomTypeCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
let bannerText = intl.formatMessage({
|
||||||
|
defaultMessage: "Campaign",
|
||||||
|
})
|
||||||
|
if (product.bookingCode) {
|
||||||
|
bannerText = product.bookingCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.rateDefinition.breakfastIncluded) {
|
||||||
|
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast included",
|
||||||
|
})}`
|
||||||
|
} else {
|
||||||
|
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast excluded",
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgsSum = sumPackages(selectedPackages)
|
||||||
|
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||||
|
|
||||||
|
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||||
|
product.public.localPrice.pricePerNight,
|
||||||
|
product.public.requestedPrice?.pricePerNight,
|
||||||
|
nights,
|
||||||
|
pkgsSum.price,
|
||||||
|
pkgsSumRequested.price
|
||||||
|
)
|
||||||
|
|
||||||
|
const pricePerNightMember = product.member
|
||||||
|
? calculatePricePerNightPriceProduct(
|
||||||
|
product.member.localPrice.pricePerNight,
|
||||||
|
product.member.requestedPrice?.pricePerNight,
|
||||||
|
nights,
|
||||||
|
pkgsSum.price,
|
||||||
|
pkgsSumRequested.price
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const isMainRoom = roomIndex === 0
|
||||||
|
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
|
||||||
|
|
||||||
|
let approximateRatePrice = undefined
|
||||||
|
if (isMainRoomAndLoggedIn && pricePerNightMember) {
|
||||||
|
approximateRatePrice = pricePerNightMember.totalRequestedPrice
|
||||||
|
} else if (
|
||||||
|
pricePerNight.totalRequestedPrice &&
|
||||||
|
pricePerNightMember?.totalRequestedPrice
|
||||||
|
) {
|
||||||
|
approximateRatePrice = `${pricePerNight.totalRequestedPrice}/${pricePerNightMember.totalRequestedPrice}`
|
||||||
|
} else if (pricePerNight.totalRequestedPrice) {
|
||||||
|
approximateRatePrice = pricePerNight.totalRequestedPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
const approximateRate =
|
||||||
|
approximateRatePrice && product.public.requestedPrice
|
||||||
|
? {
|
||||||
|
label: intl.formatMessage({
|
||||||
|
defaultMessage: "Approx.",
|
||||||
|
}),
|
||||||
|
price: approximateRatePrice,
|
||||||
|
unit: product.public.requestedPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const rateCode = isMainRoomAndLoggedIn
|
||||||
|
? product.member!.rateCode
|
||||||
|
: product.public!.rateCode
|
||||||
|
|
||||||
|
const counterRateCode = isMainRoomAndLoggedIn
|
||||||
|
? product.public?.rateCode
|
||||||
|
: product.member?.rateCode
|
||||||
|
|
||||||
|
const campaignMemberLabel =
|
||||||
|
product.rateDefinitionMember?.title || memberPriceMsg
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CampaignRateCard
|
||||||
|
key={product.rate}
|
||||||
|
approximateRate={approximateRate}
|
||||||
|
bannerText={bannerText}
|
||||||
|
handleChange={() =>
|
||||||
|
selectRate({
|
||||||
|
roomIndex,
|
||||||
|
rateCode: rateCode,
|
||||||
|
counterRateCode: counterRateCode,
|
||||||
|
roomTypeCode,
|
||||||
|
bookingCode: product.bookingCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isHighlightedRate={
|
||||||
|
!!product.rateDefinition?.displayPriceRed || isMainRoomAndLoggedIn
|
||||||
|
}
|
||||||
|
memberRate={
|
||||||
|
pricePerNightMember && !isMainRoomAndLoggedIn
|
||||||
|
? {
|
||||||
|
label: memberPriceMsg,
|
||||||
|
price: pricePerNightMember.totalPrice,
|
||||||
|
unit: `${product.member!.localPrice.currency}/${night}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
comparisonRate={
|
||||||
|
isMainRoomAndLoggedIn
|
||||||
|
? {
|
||||||
|
price: pricePerNight.totalPrice,
|
||||||
|
unit: product.public.localPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
name={`rateCode-${roomNr}-${product.public.rateCode}`}
|
||||||
|
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||||
|
rate={{
|
||||||
|
label: isMainRoomAndLoggedIn ? campaignMemberLabel : standardPriceMsg,
|
||||||
|
price:
|
||||||
|
isMainRoomAndLoggedIn && pricePerNightMember
|
||||||
|
? pricePerNightMember.totalPrice
|
||||||
|
: pricePerNight.totalPrice,
|
||||||
|
|
||||||
|
unit: `${product.public.localPrice.currency}/${night}`,
|
||||||
|
}}
|
||||||
|
rateTitle={rateTitles[product.rate].title}
|
||||||
|
omnibusRate={
|
||||||
|
product.public.localPrice.omnibusPricePerNight
|
||||||
|
? {
|
||||||
|
label: intl
|
||||||
|
.formatMessage({
|
||||||
|
defaultMessage: "Lowest price (last 30 days)",
|
||||||
|
})
|
||||||
|
.toUpperCase(),
|
||||||
|
price: product.public.localPrice.omnibusPricePerNight.toString(),
|
||||||
|
unit: product.public.localPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
value={product.public.rateCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
|
||||||
|
|
||||||
|
import {
|
||||||
|
sumPackages,
|
||||||
|
sumPackagesRequestedPrice,
|
||||||
|
} from "@/components/HotelReservation/utils"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||||
|
|
||||||
|
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||||
|
|
||||||
|
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||||
|
|
||||||
|
import type { Package } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
type CodeProps = {
|
||||||
|
nights: number
|
||||||
|
roomTypeCode: string
|
||||||
|
code: CodeProduct[]
|
||||||
|
roomIndex: number
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Code({
|
||||||
|
code,
|
||||||
|
nights,
|
||||||
|
roomTypeCode,
|
||||||
|
roomIndex,
|
||||||
|
selectedPackages,
|
||||||
|
}: CodeProps) {
|
||||||
|
return code.map((product) => {
|
||||||
|
return (
|
||||||
|
<InnerCode
|
||||||
|
key={product.rate}
|
||||||
|
codeProduct={product}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
nights={nights}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerCode({
|
||||||
|
codeProduct,
|
||||||
|
roomIndex,
|
||||||
|
roomTypeCode,
|
||||||
|
nights,
|
||||||
|
selectedPackages,
|
||||||
|
}: {
|
||||||
|
codeProduct: CodeProduct
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
nights: number
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
input: { bookingCode },
|
||||||
|
actions: { selectRate },
|
||||||
|
isRateSelected,
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
function handleSelectRate(rateCode: string) {
|
||||||
|
selectRate({ roomIndex, rateCode, roomTypeCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerText = useBannerText({
|
||||||
|
bookingCode: bookingCode ?? "",
|
||||||
|
breakfastIncluded: codeProduct.rateDefinition.breakfastIncluded,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pkgsSum = sumPackages(selectedPackages)
|
||||||
|
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||||
|
|
||||||
|
const isSelected = isRateSelected({
|
||||||
|
roomIndex,
|
||||||
|
roomTypeCode,
|
||||||
|
rate: { ...codeProduct, type: "code" },
|
||||||
|
})
|
||||||
|
|
||||||
|
if ("corporateCheque" in codeProduct) {
|
||||||
|
return (
|
||||||
|
<CorporateChequeCode
|
||||||
|
codeProduct={codeProduct}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
bannerText={bannerText}
|
||||||
|
packagesSum={pkgsSum}
|
||||||
|
handleSelectRate={handleSelectRate}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("voucher" in codeProduct) {
|
||||||
|
return (
|
||||||
|
<VoucherCode
|
||||||
|
codeProduct={codeProduct}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
bannerText={bannerText}
|
||||||
|
packagesSum={pkgsSum}
|
||||||
|
handleSelectRate={handleSelectRate}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codeProduct.public) {
|
||||||
|
return (
|
||||||
|
<PublicCode
|
||||||
|
codeProduct={codeProduct}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
bannerText={bannerText}
|
||||||
|
packagesSum={pkgsSum}
|
||||||
|
packagesSumRequested={pkgsSumRequested}
|
||||||
|
nights={nights}
|
||||||
|
handleSelectRate={handleSelectRate}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBannerText({
|
||||||
|
bookingCode,
|
||||||
|
breakfastIncluded,
|
||||||
|
}: {
|
||||||
|
breakfastIncluded: boolean
|
||||||
|
bookingCode: string
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (breakfastIncluded) {
|
||||||
|
return `${bookingCode} ∙ ${intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast included",
|
||||||
|
})}`
|
||||||
|
} else {
|
||||||
|
return `${bookingCode} ∙ ${intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast excluded",
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorporateChequeCode({
|
||||||
|
codeProduct,
|
||||||
|
roomIndex,
|
||||||
|
bannerText,
|
||||||
|
packagesSum,
|
||||||
|
handleSelectRate,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
codeProduct: Extract<CodeProduct, { corporateCheque: any }>
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
bannerText: string
|
||||||
|
packagesSum: ReturnType<typeof sumPackages>
|
||||||
|
handleSelectRate: (rateCode: string) => void
|
||||||
|
isSelected: boolean
|
||||||
|
}) {
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const intl = useIntl()
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
const { localPrice, rateCode, requestedPrice } = codeProduct.corporateCheque
|
||||||
|
|
||||||
|
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||||
|
|
||||||
|
let price = `${localPrice.numberOfCheques} CC`
|
||||||
|
|
||||||
|
if (localPrice.additionalPricePerStay) {
|
||||||
|
price = `${price} + ${localPrice.additionalPricePerStay + packagesSum.price}`
|
||||||
|
} else if (packagesSum.price) {
|
||||||
|
price = `${price} + ${packagesSum.price}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency =
|
||||||
|
localPrice.additionalPricePerStay > 0 || packagesSum.price > 0
|
||||||
|
? (localPrice.currency ?? packagesSum.currency ?? "")
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const approximateRate =
|
||||||
|
requestedPrice?.additionalPricePerStay && requestedPrice?.currency
|
||||||
|
? {
|
||||||
|
label: intl.formatMessage({
|
||||||
|
defaultMessage: "Approx.",
|
||||||
|
}),
|
||||||
|
price:
|
||||||
|
`${requestedPrice.numberOfCheques} CC + ` +
|
||||||
|
requestedPrice.additionalPricePerStay,
|
||||||
|
unit: requestedPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeRateCard
|
||||||
|
key={codeProduct.rate}
|
||||||
|
approximateRate={approximateRate}
|
||||||
|
bannerText={bannerText}
|
||||||
|
handleChange={() =>
|
||||||
|
handleSelectRate(codeProduct.corporateCheque.rateCode)
|
||||||
|
}
|
||||||
|
isSelected={isSelected}
|
||||||
|
name={`rateCode-${roomNr}-${rateCode}`}
|
||||||
|
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||||
|
rate={{
|
||||||
|
label: codeProduct.rateDefinition?.title,
|
||||||
|
price,
|
||||||
|
unit: currency,
|
||||||
|
}}
|
||||||
|
rateTitle={rateTitles[codeProduct.rate].title}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
value={rateCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicCode({
|
||||||
|
codeProduct,
|
||||||
|
roomIndex,
|
||||||
|
bannerText,
|
||||||
|
packagesSum,
|
||||||
|
packagesSumRequested,
|
||||||
|
nights,
|
||||||
|
handleSelectRate,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
codeProduct: Extract<CodeProduct, { public: unknown }>
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
bannerText: string
|
||||||
|
packagesSum: ReturnType<typeof sumPackages>
|
||||||
|
packagesSumRequested: ReturnType<typeof sumPackagesRequestedPrice>
|
||||||
|
nights: number
|
||||||
|
handleSelectRate: (rateCode: string) => void
|
||||||
|
isSelected: boolean
|
||||||
|
}) {
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const intl = useIntl()
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
if (!codeProduct.public) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||||
|
|
||||||
|
const night = intl
|
||||||
|
.formatMessage({
|
||||||
|
defaultMessage: "night",
|
||||||
|
})
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
const { localPrice, rateCode, requestedPrice } = codeProduct.public
|
||||||
|
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||||
|
localPrice.pricePerNight,
|
||||||
|
requestedPrice?.pricePerNight,
|
||||||
|
nights,
|
||||||
|
packagesSum.price,
|
||||||
|
packagesSumRequested.price
|
||||||
|
)
|
||||||
|
|
||||||
|
const approximateRate =
|
||||||
|
pricePerNight.totalRequestedPrice && requestedPrice?.currency
|
||||||
|
? {
|
||||||
|
label: intl.formatMessage({
|
||||||
|
defaultMessage: "Approx.",
|
||||||
|
}),
|
||||||
|
price: pricePerNight.totalRequestedPrice,
|
||||||
|
unit: requestedPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const regularPricePerNight = calculatePricePerNightPriceProduct(
|
||||||
|
localPrice.regularPricePerNight,
|
||||||
|
requestedPrice?.regularPricePerNight,
|
||||||
|
nights,
|
||||||
|
packagesSum.price,
|
||||||
|
packagesSumRequested.price
|
||||||
|
)
|
||||||
|
|
||||||
|
const comparisonRate =
|
||||||
|
+regularPricePerNight.totalPrice > +pricePerNight.totalPrice
|
||||||
|
? {
|
||||||
|
price: regularPricePerNight.totalPrice,
|
||||||
|
unit: localPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeRateCard
|
||||||
|
key={codeProduct.rate}
|
||||||
|
approximateRate={approximateRate}
|
||||||
|
bannerText={bannerText}
|
||||||
|
comparisonRate={comparisonRate}
|
||||||
|
handleChange={() => handleSelectRate(codeProduct.public!.rateCode)}
|
||||||
|
isSelected={isSelected}
|
||||||
|
name={`rateCode-${roomNr}-${rateCode}`}
|
||||||
|
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||||
|
rate={{
|
||||||
|
label: codeProduct.rateDefinition?.title,
|
||||||
|
price: pricePerNight.totalPrice,
|
||||||
|
unit: `${localPrice.currency}/${night}`,
|
||||||
|
}}
|
||||||
|
rateTitle={rateTitles[codeProduct.rate].title}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
value={rateCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoucherCode({
|
||||||
|
codeProduct,
|
||||||
|
bannerText,
|
||||||
|
packagesSum,
|
||||||
|
roomIndex,
|
||||||
|
handleSelectRate,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
codeProduct: Extract<CodeProduct, { voucher: any }>
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
bannerText: string
|
||||||
|
packagesSum: ReturnType<typeof sumPackages>
|
||||||
|
handleSelectRate: (rateCode: string) => void
|
||||||
|
isSelected: boolean
|
||||||
|
}) {
|
||||||
|
const roomNr = roomIndex + 1
|
||||||
|
const intl = useIntl()
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
const { numberOfVouchers, rateCode } = codeProduct.voucher
|
||||||
|
|
||||||
|
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||||
|
|
||||||
|
const voucherMsg = intl
|
||||||
|
.formatMessage({
|
||||||
|
defaultMessage: "Voucher",
|
||||||
|
})
|
||||||
|
.toUpperCase()
|
||||||
|
let price = `${numberOfVouchers} ${voucherMsg}`
|
||||||
|
if (packagesSum.price) {
|
||||||
|
price = `${price} + ${packagesSum.price}`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CodeRateCard
|
||||||
|
key={codeProduct.rate}
|
||||||
|
bannerText={bannerText}
|
||||||
|
handleChange={() => handleSelectRate(codeProduct.voucher.rateCode)}
|
||||||
|
isSelected={isSelected}
|
||||||
|
name={`rateCode-${roomNr}-${rateCode}`}
|
||||||
|
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||||
|
rate={{
|
||||||
|
label: codeProduct.rateDefinition?.title,
|
||||||
|
price,
|
||||||
|
unit: packagesSum.currency ?? "",
|
||||||
|
}}
|
||||||
|
rateTitle={rateTitles[codeProduct.rate].title}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
value={rateCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRateTermDetails(codeProduct: CodeProduct): RateTermDetails {
|
||||||
|
return codeProduct.rateDefinitionMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: codeProduct.rateDefinition.title,
|
||||||
|
terms: codeProduct.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: codeProduct.rateDefinitionMember.title,
|
||||||
|
terms: codeProduct.rateDefinitionMember.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: codeProduct.rateDefinition.title,
|
||||||
|
terms: codeProduct.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateTermDetails = { title: string; terms: string[] }[]
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
|
||||||
|
|
||||||
|
import { sumPackages } from "@/components/HotelReservation/utils"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AvailabilityWithRoomInfo,
|
||||||
|
Package,
|
||||||
|
} from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
type RedemptionsProps = {
|
||||||
|
redemptions: AvailabilityWithRoomInfo["redemptions"]
|
||||||
|
roomTypeCode: string
|
||||||
|
selectedPackages: Package[]
|
||||||
|
roomIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Redemptions({
|
||||||
|
redemptions,
|
||||||
|
roomTypeCode,
|
||||||
|
roomIndex,
|
||||||
|
selectedPackages,
|
||||||
|
}: RedemptionsProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
const {
|
||||||
|
actions: { selectRate },
|
||||||
|
selectedRates,
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||||
|
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||||
|
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedFilter === BookingCodeFilterEnum.Discounted ||
|
||||||
|
!redemptions.length
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewardNight = intl.formatMessage({
|
||||||
|
defaultMessage: "Reward night",
|
||||||
|
})
|
||||||
|
const pkgsSum = sumPackages(selectedPackages)
|
||||||
|
|
||||||
|
const breakfastIncluded = intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast included",
|
||||||
|
})
|
||||||
|
const breakfastExcluded = intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast excluded",
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedRateCode =
|
||||||
|
selectedRate &&
|
||||||
|
"redemption" in selectedRate &&
|
||||||
|
selectedRate.roomInfo.roomTypeCode === roomTypeCode
|
||||||
|
? selectedRate.redemption.rateCode
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const rates = redemptions.map((r) => {
|
||||||
|
let additionalPrice
|
||||||
|
if (r.redemption.localPrice.additionalPricePerStay) {
|
||||||
|
additionalPrice =
|
||||||
|
r.redemption.localPrice.additionalPricePerStay + pkgsSum.price
|
||||||
|
} else if (pkgsSum.price) {
|
||||||
|
additionalPrice = pkgsSum.price
|
||||||
|
}
|
||||||
|
let additionalPriceCurrency
|
||||||
|
if (r.redemption.localPrice.currency) {
|
||||||
|
additionalPriceCurrency = r.redemption.localPrice.currency
|
||||||
|
} else if (pkgsSum.currency) {
|
||||||
|
additionalPriceCurrency = pkgsSum.currency
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
additionalPrice:
|
||||||
|
additionalPrice && additionalPriceCurrency
|
||||||
|
? {
|
||||||
|
currency: additionalPriceCurrency,
|
||||||
|
price: additionalPrice.toString(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
currency: "PTS",
|
||||||
|
isDisabled: !r.redemption.hasEnoughPoints,
|
||||||
|
points: r.redemption.localPrice.pointsPerStay.toString(),
|
||||||
|
rateCode: r.redemption.rateCode,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const notEnoughPoints = rates.every((rate) => rate.isDisabled)
|
||||||
|
const firstRedemption = redemptions[0]
|
||||||
|
const bannerText = firstRedemption.rateDefinition.breakfastIncluded
|
||||||
|
? `${rewardNight} ∙ ${breakfastIncluded}`
|
||||||
|
: `${rewardNight} ∙ ${breakfastExcluded}`
|
||||||
|
|
||||||
|
const rateTermDetails = [
|
||||||
|
{
|
||||||
|
title: rateTitles[firstRedemption.rate].title,
|
||||||
|
terms: firstRedemption.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PointsRateCard
|
||||||
|
key={firstRedemption.rate}
|
||||||
|
bannerText={bannerText}
|
||||||
|
onRateSelect={(rateCode: string) => {
|
||||||
|
selectRate({
|
||||||
|
roomIndex: roomIndex,
|
||||||
|
rateCode: rateCode,
|
||||||
|
roomTypeCode: roomTypeCode,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
paymentTerm={rateTitles[firstRedemption.rate].paymentTerm}
|
||||||
|
rates={rates}
|
||||||
|
rateTitle={rateTitles[firstRedemption.rate].title}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
selectedRate={selectedRateCode}
|
||||||
|
isNotEnoughPoints={notEnoughPoints}
|
||||||
|
notEnoughPointsText={intl.formatMessage({
|
||||||
|
defaultMessage: "Not enough points",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||||
|
import RegularRateCard from "@scandic-hotels/design-system/RegularRateCard"
|
||||||
|
|
||||||
|
import {
|
||||||
|
sumPackages,
|
||||||
|
sumPackagesRequestedPrice,
|
||||||
|
} from "@/components/HotelReservation/utils"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||||
|
import { isValidClientSession } from "@/utils/clientSession"
|
||||||
|
|
||||||
|
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||||
|
|
||||||
|
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
interface Rate {
|
||||||
|
label: string
|
||||||
|
price: string
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rates {
|
||||||
|
memberRate?: Rate
|
||||||
|
rate?: Rate
|
||||||
|
}
|
||||||
|
type RegularRateProps = {
|
||||||
|
nights: number
|
||||||
|
regular: AvailabilityWithRoomInfo["regular"]
|
||||||
|
roomIndex: number
|
||||||
|
roomTypeCode: string
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegularRate({
|
||||||
|
nights,
|
||||||
|
regular,
|
||||||
|
roomTypeCode,
|
||||||
|
roomIndex,
|
||||||
|
selectedPackages,
|
||||||
|
}: RegularRateProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isUserLoggedIn = isValidClientSession(session)
|
||||||
|
|
||||||
|
return regular.map((product, ix) => (
|
||||||
|
<Inner
|
||||||
|
key={ix}
|
||||||
|
product={product}
|
||||||
|
isUserLoggedIn={isUserLoggedIn}
|
||||||
|
nights={nights}
|
||||||
|
roomTypeCode={roomTypeCode}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({
|
||||||
|
product,
|
||||||
|
isUserLoggedIn,
|
||||||
|
nights,
|
||||||
|
roomTypeCode,
|
||||||
|
roomIndex,
|
||||||
|
selectedPackages,
|
||||||
|
}: {
|
||||||
|
product: AvailabilityWithRoomInfo["regular"][number]
|
||||||
|
isUserLoggedIn: boolean
|
||||||
|
nights: number
|
||||||
|
roomTypeCode: string
|
||||||
|
roomIndex: number
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const rateTitles = useRateTitles()
|
||||||
|
const {
|
||||||
|
isRateSelected,
|
||||||
|
bookingCodeFilter,
|
||||||
|
actions: { selectRate },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
const isMainRoom = roomIndex === 0
|
||||||
|
|
||||||
|
if (bookingCodeFilter === BookingCodeFilterEnum.Discounted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const night = intl
|
||||||
|
.formatMessage({
|
||||||
|
defaultMessage: "night",
|
||||||
|
})
|
||||||
|
.toUpperCase()
|
||||||
|
const pkgsSum = sumPackages(selectedPackages)
|
||||||
|
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||||
|
|
||||||
|
const standardPriceMsg = intl.formatMessage({
|
||||||
|
defaultMessage: "Standard price",
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberPriceMsg = intl.formatMessage({
|
||||||
|
defaultMessage: "Member price",
|
||||||
|
})
|
||||||
|
|
||||||
|
const approxMsg = intl.formatMessage({
|
||||||
|
defaultMessage: "Approx.",
|
||||||
|
})
|
||||||
|
|
||||||
|
const { member, public: standard } = product
|
||||||
|
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
|
||||||
|
const isMainRoomLoggedInWithoutMember =
|
||||||
|
isMainRoomAndLoggedIn && !product.member
|
||||||
|
const noRateAvailable = !product.member && !product.public
|
||||||
|
const hideStandardPrice = isMainRoomAndLoggedIn && !!member
|
||||||
|
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
|
||||||
|
const rateCode = hideStandardPrice ? member.rateCode : standard?.rateCode
|
||||||
|
const counterRateCode = isMainRoomAndLoggedIn
|
||||||
|
? standard?.rateCode
|
||||||
|
: member?.rateCode
|
||||||
|
|
||||||
|
if (
|
||||||
|
noRateAvailable ||
|
||||||
|
isMainRoomLoggedInWithoutMember ||
|
||||||
|
!rateCode ||
|
||||||
|
isNotLoggedInAndOnlyMemberRate
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NoRateAvailableCard
|
||||||
|
key={product.rate}
|
||||||
|
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||||
|
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||||
|
rateTitle={rateTitles[product.rate].title}
|
||||||
|
variant="Regular"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberPricePerNight = member
|
||||||
|
? calculatePricePerNightPriceProduct(
|
||||||
|
member.localPrice.pricePerNight,
|
||||||
|
member.requestedPrice?.pricePerNight,
|
||||||
|
nights,
|
||||||
|
pkgsSum.price,
|
||||||
|
pkgsSumRequested.price
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const standardPricePerNight = standard
|
||||||
|
? calculatePricePerNightPriceProduct(
|
||||||
|
standard.localPrice.pricePerNight,
|
||||||
|
standard.requestedPrice?.pricePerNight,
|
||||||
|
nights,
|
||||||
|
pkgsSum.price,
|
||||||
|
pkgsSumRequested.price
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let approximateMemberRatePrice = null
|
||||||
|
const rates: Rates = {}
|
||||||
|
if (memberPricePerNight) {
|
||||||
|
rates.memberRate = {
|
||||||
|
label: memberPriceMsg,
|
||||||
|
price: memberPricePerNight.totalPrice,
|
||||||
|
unit: `${member!.localPrice.currency}/${night}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberPricePerNight.totalRequestedPrice) {
|
||||||
|
approximateMemberRatePrice = memberPricePerNight.totalRequestedPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let approximateStandardRatePrice = null
|
||||||
|
if (standardPricePerNight) {
|
||||||
|
rates.rate = {
|
||||||
|
label: standardPriceMsg,
|
||||||
|
price: standardPricePerNight.totalPrice,
|
||||||
|
unit: `${standard!.localPrice.currency}/${night}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
|
||||||
|
approximateStandardRatePrice = standardPricePerNight.totalRequestedPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let approximatePrice = ""
|
||||||
|
if (approximateStandardRatePrice && approximateMemberRatePrice) {
|
||||||
|
approximatePrice = `${approximateStandardRatePrice}/${approximateMemberRatePrice}`
|
||||||
|
} else if (approximateStandardRatePrice) {
|
||||||
|
approximatePrice = approximateStandardRatePrice
|
||||||
|
} else if (approximateMemberRatePrice) {
|
||||||
|
approximatePrice = approximateMemberRatePrice
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedCurrency =
|
||||||
|
standard?.requestedPrice?.currency || member?.requestedPrice?.currency
|
||||||
|
const approximateRate =
|
||||||
|
approximatePrice && requestedCurrency
|
||||||
|
? {
|
||||||
|
label: approxMsg,
|
||||||
|
price: approximatePrice,
|
||||||
|
unit: requestedCurrency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const rateTermDetails = product.rateDefinitionMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: standardPriceMsg,
|
||||||
|
terms: product.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: memberPriceMsg,
|
||||||
|
terms: product.rateDefinitionMember.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: standardPriceMsg,
|
||||||
|
terms: product.rateDefinition.generalTerms,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const isSelected = isRateSelected({
|
||||||
|
roomIndex,
|
||||||
|
rate: { ...product, type: "regular" },
|
||||||
|
roomTypeCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMemberRateActive = isUserLoggedIn && isMainRoom && !!member
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RegularRateCard
|
||||||
|
{...rates}
|
||||||
|
key={product.rate}
|
||||||
|
approximateRate={approximateRate}
|
||||||
|
handleChange={() => {
|
||||||
|
selectRate({
|
||||||
|
roomIndex: roomIndex,
|
||||||
|
rateCode: rateCode,
|
||||||
|
roomTypeCode: roomTypeCode,
|
||||||
|
counterRateCode: counterRateCode,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
isMemberRateActive={isMemberRateActive}
|
||||||
|
isSelected={isSelected}
|
||||||
|
name={`rateCode-${roomIndex + 1}-${rateCode}`}
|
||||||
|
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||||
|
rateTitle={rateTitles[product.rate].title}
|
||||||
|
value={rateCode}
|
||||||
|
rateTermDetails={rateTermDetails}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||||
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { BreakfastMessage } from "./BreakfastMessage"
|
||||||
|
import Campaign from "./Campaign"
|
||||||
|
import Code from "./Code"
|
||||||
|
import Redemptions from "./Redemptions"
|
||||||
|
import { RegularRate } from "./Regular"
|
||||||
|
|
||||||
|
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
export interface RatesProps {
|
||||||
|
roomConfiguration: AvailabilityWithRoomInfo
|
||||||
|
roomIndex: number
|
||||||
|
selectedPackages: Package[]
|
||||||
|
}
|
||||||
|
export function Rates({
|
||||||
|
roomConfiguration: {
|
||||||
|
breakfastIncludedInAllRates,
|
||||||
|
breakfastIncludedInAllRatesMember,
|
||||||
|
campaign,
|
||||||
|
code,
|
||||||
|
redemptions,
|
||||||
|
regular,
|
||||||
|
roomTypeCode,
|
||||||
|
},
|
||||||
|
selectedPackages,
|
||||||
|
roomIndex,
|
||||||
|
}: RatesProps) {
|
||||||
|
const {
|
||||||
|
bookingCodeFilter,
|
||||||
|
input: { nights },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
const sharedProps = {
|
||||||
|
nights,
|
||||||
|
roomTypeCode,
|
||||||
|
roomIndex,
|
||||||
|
selectedPackages,
|
||||||
|
}
|
||||||
|
const showAllRates = bookingCodeFilter === BookingCodeFilterEnum.All
|
||||||
|
const hasBookingCodeRates = !!(campaign.length || code.length)
|
||||||
|
const hasRegularRates = !!regular.length
|
||||||
|
const showDivider = showAllRates && hasBookingCodeRates && hasRegularRates
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Code {...sharedProps} code={code} />
|
||||||
|
<Campaign {...sharedProps} campaign={campaign} />
|
||||||
|
<Redemptions {...sharedProps} redemptions={redemptions} />
|
||||||
|
{showDivider ? <Divider color="Border/Divider/Subtle" /> : null}
|
||||||
|
<BreakfastMessage
|
||||||
|
breakfastIncludedMember={breakfastIncludedInAllRatesMember}
|
||||||
|
breakfastIncludedStandard={breakfastIncludedInAllRates}
|
||||||
|
hasRegularRates={hasRegularRates && showAllRates}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
/>
|
||||||
|
<RegularRate {...sharedProps} regular={regular} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type {
|
||||||
|
CorporateChequeProduct,
|
||||||
|
PriceProduct,
|
||||||
|
VoucherProduct,
|
||||||
|
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||||
|
|
||||||
|
import type { SelectedRate } from "@/types/stores/rates"
|
||||||
|
|
||||||
|
export function isSelectedPriceProduct(
|
||||||
|
product: PriceProduct,
|
||||||
|
selectedRate: SelectedRate | null,
|
||||||
|
roomTypeCode: string
|
||||||
|
) {
|
||||||
|
if (!selectedRate || roomTypeCode !== selectedRate.roomTypeCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { member, public: standard } = product
|
||||||
|
let isSelected = false
|
||||||
|
if (
|
||||||
|
"member" in selectedRate.product &&
|
||||||
|
selectedRate.product.member &&
|
||||||
|
member
|
||||||
|
) {
|
||||||
|
isSelected = selectedRate.product.member.rateCode === member.rateCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"public" in selectedRate.product &&
|
||||||
|
selectedRate.product.public &&
|
||||||
|
standard
|
||||||
|
) {
|
||||||
|
isSelected = selectedRate.product.public.rateCode === standard.rateCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSelected
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectedCorporateCheque(
|
||||||
|
product: CorporateChequeProduct,
|
||||||
|
selectedRate: SelectedRate | null,
|
||||||
|
roomTypeCode: string
|
||||||
|
) {
|
||||||
|
if (!selectedRate || !("corporateCheque" in selectedRate.product)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameRateCode =
|
||||||
|
product.corporateCheque.rateCode ===
|
||||||
|
selectedRate.product.corporateCheque.rateCode
|
||||||
|
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||||
|
return isSameRateCode && isSameRoomTypeCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectedVoucher(
|
||||||
|
product: VoucherProduct,
|
||||||
|
selectedRate: SelectedRate | null,
|
||||||
|
roomTypeCode: string
|
||||||
|
) {
|
||||||
|
if (!selectedRate || !("voucher" in selectedRate.product)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameRateCode =
|
||||||
|
product.voucher.rateCode === selectedRate.product.voucher.rateCode
|
||||||
|
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||||
|
return isSameRateCode && isSameRoomTypeCode
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export function calculatePricePerNightPriceProduct(
|
||||||
|
pricePerNight: number,
|
||||||
|
requestedPricePerNight: number | undefined,
|
||||||
|
nights: number,
|
||||||
|
packagesSumLocal: number,
|
||||||
|
packagesSumRequested: number
|
||||||
|
) {
|
||||||
|
const totalPrice = packagesSumLocal
|
||||||
|
? Math.floor(pricePerNight + packagesSumLocal / nights)
|
||||||
|
: Math.floor(pricePerNight)
|
||||||
|
|
||||||
|
let totalRequestedPrice = undefined
|
||||||
|
if (requestedPricePerNight) {
|
||||||
|
if (packagesSumRequested) {
|
||||||
|
totalRequestedPrice = Math.floor(
|
||||||
|
requestedPricePerNight + packagesSumRequested / nights
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
totalRequestedPrice = Math.floor(requestedPricePerNight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPrice: totalPrice.toString(),
|
||||||
|
totalRequestedPrice: totalRequestedPrice?.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.imageContainer {
|
||||||
|
margin: 0 calc(-1 * var(--Spacing-x2));
|
||||||
|
min-height: 190px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-multiroom="true"] .imageContainer {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
left: 12px;
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background-color: var(--Main-Grey-White);
|
||||||
|
border-radius: var(--Corner-radius-sm);
|
||||||
|
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer img {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleSidePeek {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
color: var(--Component-Button-Brand-Secondary-On-fill-Inverted);
|
||||||
|
background-color: var(--Surface-Brand-Primary-1-OnSurface-Default);
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory {
|
||||||
|
color: var(--Text-Interactive-Default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client"
|
||||||
|
import { memo } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||||
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
|
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
|
import ToggleSidePeek from "../Details/ToggleSidePeek"
|
||||||
|
|
||||||
|
import styles from "./image.module.css"
|
||||||
|
|
||||||
|
import type { ApiImage } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
import type { RoomConfiguration } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||||
|
|
||||||
|
export type RoomListItemImageProps = Pick<
|
||||||
|
RoomConfiguration,
|
||||||
|
"roomType" | "roomTypeCode" | "roomsLeft"
|
||||||
|
> & {
|
||||||
|
selectedPackages: PackageEnum[]
|
||||||
|
images: ApiImage[]
|
||||||
|
hotelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoomImage = memo(function RoomImage({
|
||||||
|
roomsLeft,
|
||||||
|
roomType,
|
||||||
|
roomTypeCode,
|
||||||
|
selectedPackages,
|
||||||
|
images,
|
||||||
|
hotelId,
|
||||||
|
}: RoomListItemImageProps) {
|
||||||
|
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
<div className={styles.chipContainer}>
|
||||||
|
<LowInventoryTag roomsLeft={roomsLeft} />
|
||||||
|
|
||||||
|
{selectedPackages.map((pkg) => (
|
||||||
|
<span className={styles.chip} key={pkg}>
|
||||||
|
{IconForFeatureCode({ featureCode: pkg, size: 16 })}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ImageGallery
|
||||||
|
images={galleryImages}
|
||||||
|
title={roomType}
|
||||||
|
fill
|
||||||
|
imageCountPosition="top"
|
||||||
|
/>
|
||||||
|
<div className={styles.toggleSidePeek}>
|
||||||
|
{roomTypeCode && (
|
||||||
|
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default RoomImage
|
||||||
|
|
||||||
|
function LowInventoryTag({ roomsLeft }: { roomsLeft: number }) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
|
||||||
|
|
||||||
|
if (!showLowInventory) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.chip}>
|
||||||
|
<Typography variant="Tag/sm">
|
||||||
|
<p className={styles.inventory}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{amount, number} left",
|
||||||
|
},
|
||||||
|
{ amount: roomsLeft }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import Caption from "@scandic-hotels/design-system/Caption"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import styles from "./notAvailable.module.css"
|
||||||
|
|
||||||
|
export default function RoomNotAvailable() {
|
||||||
|
const intl = useIntl()
|
||||||
|
return (
|
||||||
|
<div className={styles.noRoomsContainer}>
|
||||||
|
<div className={styles.noRooms}>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="error_circle_rounded"
|
||||||
|
color="Icon/Feedback/Error"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<Caption color="uiTextHighContrast" type="bold">
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "This room is not available",
|
||||||
|
})}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.noRooms {
|
||||||
|
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||||
|
|
||||||
|
import Details from "./Details"
|
||||||
|
import { listItemVariants } from "./listItemVariants"
|
||||||
|
import { Rates } from "./Rates"
|
||||||
|
import RoomImage from "./RoomImage"
|
||||||
|
import RoomNotAvailable from "./RoomNotAvailable"
|
||||||
|
|
||||||
|
import styles from "./roomListItem.module.css"
|
||||||
|
|
||||||
|
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||||
|
|
||||||
|
export type RoomListItemProps = {
|
||||||
|
room: AvailabilityWithRoomInfo
|
||||||
|
selectedPackages: Package[]
|
||||||
|
roomIndex: number
|
||||||
|
hotelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomListItem({
|
||||||
|
room,
|
||||||
|
selectedPackages,
|
||||||
|
roomIndex,
|
||||||
|
hotelId,
|
||||||
|
}: RoomListItemProps) {
|
||||||
|
if (!room || !room.roomInfo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const classNames = listItemVariants({
|
||||||
|
availability:
|
||||||
|
room.status === AvailabilityEnum.NotAvailable
|
||||||
|
? "noAvailability"
|
||||||
|
: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={classNames}>
|
||||||
|
<RoomImage
|
||||||
|
roomType={room.roomType}
|
||||||
|
roomTypeCode={room.roomTypeCode}
|
||||||
|
roomsLeft={room.roomsLeft}
|
||||||
|
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||||
|
images={room.roomInfo.images ?? []}
|
||||||
|
hotelId={hotelId}
|
||||||
|
/>
|
||||||
|
<Details roomInfo={room.roomInfo} />
|
||||||
|
|
||||||
|
<div className={styles.container}>
|
||||||
|
{room.status === AvailabilityEnum.NotAvailable ? (
|
||||||
|
<RoomNotAvailable />
|
||||||
|
) : (
|
||||||
|
<Rates
|
||||||
|
roomConfiguration={room}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
import styles from "./roomListItem.module.css"
|
||||||
|
|
||||||
|
export const listItemVariants = cva(styles.listItem, {
|
||||||
|
variants: {
|
||||||
|
availability: {
|
||||||
|
noAvailability: styles.noAvailability,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.listItem {
|
||||||
|
align-content: flex-start;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
display: grid;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-multiroom="true"] .listItem {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem.noAvailability {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||||
|
|
||||||
|
import styles from "./roomsListSkeleton.module.css"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomsListSkeleton({ count = 4 }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.skeletonContainer}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<RoomCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
export default function ScrollToList() {
|
||||||
|
const {
|
||||||
|
input: { isMultiRoom },
|
||||||
|
selectedRates,
|
||||||
|
} = useSelectRateContext()
|
||||||
|
const selectedRateCode = selectedRates.rates[0]
|
||||||
|
? `${selectedRates.rates[0].rateDefinition.rateCode}${selectedRates.rates[0].roomInfo.roomTypeCode}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMultiRoom) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedRateCode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required to prevent the history.pushState on the first selection
|
||||||
|
// to scroll user back to top
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const SCROLL_OFFSET = 173 // summary on mobile is 163px
|
||||||
|
|
||||||
|
const selectedRateCard: HTMLElement | null = document.querySelector(
|
||||||
|
`.${styles.roomList} label:has(input[type=radio]:checked)`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectedRateCard) {
|
||||||
|
const elementPosition = selectedRateCard.getBoundingClientRect().top
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const offsetPosition =
|
||||||
|
elementPosition +
|
||||||
|
window.scrollY -
|
||||||
|
(windowHeight - selectedRateCard.offsetHeight - SCROLL_OFFSET)
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: "instant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [isMultiRoom, selectedRateCode])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { RoomListItem } from "./RoomListItem"
|
||||||
|
import { RoomsListSkeleton } from "./RoomsListSkeleton"
|
||||||
|
import ScrollToList from "./ScrollToList"
|
||||||
|
|
||||||
|
import styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
export default function RoomsList({ roomIndex }: { roomIndex: number }) {
|
||||||
|
const { getAvailabilityForRoom, isFetching, input, getPackagesForRoom } =
|
||||||
|
useSelectRateContext()
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <RoomsListSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelId = input?.data?.booking.hotelId
|
||||||
|
if (!hotelId) {
|
||||||
|
throw new Error("Hotel ID is required to display room availability")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollToList />
|
||||||
|
<ul className={styles.roomList}>
|
||||||
|
{getAvailabilityForRoom(roomIndex)?.map((room, ix) => {
|
||||||
|
return (
|
||||||
|
<RoomListItem
|
||||||
|
key={room.roomTypeCode + ix}
|
||||||
|
room={room}
|
||||||
|
selectedPackages={getPackagesForRoom(roomIndex).selectedPackages}
|
||||||
|
roomIndex={roomIndex}
|
||||||
|
hotelId={hotelId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.roomList {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roomList > li {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.container {
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonContainer {
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
/* used to hide overflowing rows */
|
||||||
|
grid-template-rows: auto;
|
||||||
|
grid-auto-rows: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { MultiRoomWrapper } from "./MultiRoomWrapper"
|
||||||
|
import NoAvailabilityAlert from "./NoAvailabilityAlert"
|
||||||
|
import { RoomsHeader } from "./RoomsHeader"
|
||||||
|
import RoomsList from "./RoomsList"
|
||||||
|
|
||||||
|
import styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
export default function Rooms() {
|
||||||
|
const {
|
||||||
|
availability,
|
||||||
|
input: { isMultiRoom },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
if (!availability) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
{availability.data?.map((_room, idx) => {
|
||||||
|
return (
|
||||||
|
<MultiRoomWrapper
|
||||||
|
key={`${idx}`}
|
||||||
|
roomIndex={idx}
|
||||||
|
isMultiRoom={isMultiRoom}
|
||||||
|
>
|
||||||
|
<RoomsHeader roomIndex={idx} />
|
||||||
|
<NoAvailabilityAlert roomIndex={idx} />
|
||||||
|
<RoomsList roomIndex={idx} />
|
||||||
|
</MultiRoomWrapper>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.content {
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
padding: var(--Spacing-x5) 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.container {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterContainer {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonContainer {
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
/* used to hide overflowing rows */
|
||||||
|
grid-template-rows: auto;
|
||||||
|
grid-auto-rows: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||||
|
|
||||||
|
import styles from "./RoomsContainerSkeleton.module.css"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomsContainerSkeleton({ count = 4 }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.filterContainer}></div>
|
||||||
|
<div className={styles.skeletonContainer}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<RoomCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.errorContainer {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--Spacing-x-one-and-half) 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--max-width-page);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { TRPCClientError } from "@trpc/client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
||||||
|
|
||||||
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
|
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||||
|
|
||||||
|
import { RateSummary } from "./RateSummary"
|
||||||
|
import Rooms from "./Rooms"
|
||||||
|
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
|
||||||
|
|
||||||
|
import styles from "./index.module.css"
|
||||||
|
|
||||||
|
import type { AppRouter } from "@scandic-hotels/trpc/routers/appRouter"
|
||||||
|
|
||||||
|
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||||
|
|
||||||
|
export function RoomsContainer({}: RoomsContainerProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const {
|
||||||
|
availability: { error, isFetching, isError },
|
||||||
|
input: { hasError: hasInputError },
|
||||||
|
} = useSelectRateContext()
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <RoomsContainerSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || hasInputError) {
|
||||||
|
const errorMessage = getErrorMessage(error, intl)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<Alert type={AlertTypeEnum.Alarm} heading={errorMessage} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Rooms />
|
||||||
|
<RateSummary />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, intl: ReturnType<typeof useIntl>) {
|
||||||
|
if (!isTRPCClientError(error)) {
|
||||||
|
return intl.formatMessage({
|
||||||
|
defaultMessage: "Something went wrong",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstError = error.data?.zodError?.formErrors?.at(0)
|
||||||
|
|
||||||
|
switch (firstError) {
|
||||||
|
case "FROMDATE_INVALID":
|
||||||
|
case "TODATE_INVALID":
|
||||||
|
case "TODATE_MUST_BE_AFTER_FROMDATE":
|
||||||
|
case "FROMDATE_CANNOT_BE_IN_THE_PAST": {
|
||||||
|
return intl.formatMessage({
|
||||||
|
defaultMessage: "Invalid dates",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return intl.formatMessage({
|
||||||
|
defaultMessage: "Something went wrong",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTRPCClientError(
|
||||||
|
cause: unknown
|
||||||
|
): cause is TRPCClientError<AppRouter> {
|
||||||
|
return cause instanceof TRPCClientError
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseSelectRateSearchParams,
|
||||||
|
searchParamsToRecord,
|
||||||
|
} from "@scandic-hotels/booking-flow/utils/url"
|
||||||
|
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||||
|
|
||||||
|
import TrackingSDK from "@/components/TrackingSDK"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import { getValidDates } from "../getValidDates"
|
||||||
|
import { getSelectRateTracking } from "./tracking"
|
||||||
|
|
||||||
|
export default function Tracking({
|
||||||
|
hotelId,
|
||||||
|
hotelName,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
}: {
|
||||||
|
hotelId: string
|
||||||
|
hotelName: string
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
}) {
|
||||||
|
const lang = useLang()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const booking = parseSelectRateSearchParams(searchParamsToRecord(params))
|
||||||
|
|
||||||
|
if (!booking) return null
|
||||||
|
|
||||||
|
const { fromDate, toDate } = getValidDates(booking.fromDate, booking.toDate)
|
||||||
|
|
||||||
|
const { rooms, searchType, bookingCode, city: paramCity } = booking
|
||||||
|
|
||||||
|
const arrivalDate = fromDate.toDate()
|
||||||
|
const departureDate = toDate.toDate()
|
||||||
|
|
||||||
|
const { hotelsTrackingData, pageTrackingData } = getSelectRateTracking({
|
||||||
|
lang,
|
||||||
|
arrivalDate,
|
||||||
|
departureDate,
|
||||||
|
hotelId,
|
||||||
|
hotelName,
|
||||||
|
country,
|
||||||
|
hotelCity: city,
|
||||||
|
paramCity,
|
||||||
|
bookingCode,
|
||||||
|
isRedemption: searchType === SEARCH_TYPE_REDEMPTION,
|
||||||
|
rooms,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
type SelectRateTrackingInput = {
|
||||||
|
lang: Lang
|
||||||
|
arrivalDate: Date
|
||||||
|
departureDate: Date
|
||||||
|
hotelId: string
|
||||||
|
hotelName: string
|
||||||
|
country: string | undefined
|
||||||
|
hotelCity: string | undefined
|
||||||
|
paramCity: string | undefined
|
||||||
|
bookingCode?: string
|
||||||
|
isRedemption?: boolean
|
||||||
|
rooms?: Room[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectRateTracking({
|
||||||
|
lang,
|
||||||
|
arrivalDate,
|
||||||
|
departureDate,
|
||||||
|
hotelId,
|
||||||
|
hotelName,
|
||||||
|
country,
|
||||||
|
hotelCity,
|
||||||
|
paramCity,
|
||||||
|
bookingCode,
|
||||||
|
isRedemption = false,
|
||||||
|
rooms = [],
|
||||||
|
}: SelectRateTrackingInput) {
|
||||||
|
const pageTrackingData: TrackingSDKPageData = {
|
||||||
|
channel: TrackingChannelEnum.hotelreservation,
|
||||||
|
domainLanguage: lang,
|
||||||
|
pageId: "select-rate",
|
||||||
|
pageName: "hotelreservation|select-rate",
|
||||||
|
pageType: "bookingroomsandratespage",
|
||||||
|
siteSections: "hotelreservation|select-rate",
|
||||||
|
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"),
|
||||||
|
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),
|
||||||
|
hotelID: hotelId,
|
||||||
|
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||||
|
noOfAdults: adultsInRoom.join(","),
|
||||||
|
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
|
||||||
|
noOfRooms: rooms?.length ?? 0,
|
||||||
|
region: hotelCity,
|
||||||
|
searchTerm: paramCity ?? hotelName,
|
||||||
|
searchType: "hotel",
|
||||||
|
bookingCode: bookingCode ?? "n/a",
|
||||||
|
rewardNight: isRedemption ? "yes" : "no",
|
||||||
|
specialRoomType: rooms
|
||||||
|
?.map((room) => {
|
||||||
|
const packages = room.packages
|
||||||
|
?.map((pkg) => {
|
||||||
|
if (pkg === RoomPackageCodeEnum.ACCESSIBILITY_ROOM) {
|
||||||
|
return "accessibility"
|
||||||
|
} else if (pkg === RoomPackageCodeEnum.ALLERGY_ROOM) {
|
||||||
|
return "allergy friendly"
|
||||||
|
} else if (pkg === RoomPackageCodeEnum.PET_ROOM) {
|
||||||
|
return "pet room"
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(",")
|
||||||
|
|
||||||
|
return packages ?? ""
|
||||||
|
})
|
||||||
|
.join("|"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hotelsTrackingData,
|
||||||
|
pageTrackingData,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { getValidFromDate, getValidToDate } from "./getValidDates"
|
||||||
|
|
||||||
|
const NOW = new Date("2020-10-01T00:00:00Z")
|
||||||
|
|
||||||
|
describe("getValidFromDate", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers({ now: NOW })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getValidFromDate", () => {
|
||||||
|
it("returns today when empty string is provided", () => {
|
||||||
|
const actual = getValidFromDate("")
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns today when undefined is provided", () => {
|
||||||
|
const actual = getValidFromDate(undefined)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns given date in utc", () => {
|
||||||
|
const actual = getValidFromDate("2024-01-01")
|
||||||
|
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getValidToDate", () => {
|
||||||
|
it("returns day after fromDate when empty string is provided", () => {
|
||||||
|
const actual = getValidToDate("", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns day after fromDate when undefined is provided", () => {
|
||||||
|
const actual = getValidToDate(undefined, NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns given date in utc", () => {
|
||||||
|
const actual = getValidToDate("2024-01-01", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fallsback to day after fromDate when given date is before fromDate", () => {
|
||||||
|
const actual = getValidToDate("2020-09-30", NOW)
|
||||||
|
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
|
||||||
|
import type { Dayjs } from "dayjs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid dates from stringFromDate and stringToDate making sure that they are not in the past and chronologically correct
|
||||||
|
* @example const { fromDate, toDate} = getValidDates("2021-01-01", "2021-01-02")
|
||||||
|
*/
|
||||||
|
export function getValidDates(
|
||||||
|
stringFromDate: string | undefined,
|
||||||
|
stringToDate: string | undefined
|
||||||
|
): { fromDate: Dayjs; toDate: Dayjs } {
|
||||||
|
const fromDate = getValidFromDate(stringFromDate)
|
||||||
|
const toDate = getValidToDate(stringToDate, fromDate)
|
||||||
|
|
||||||
|
return { fromDate, toDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid fromDate from stringFromDate making sure that it is not in the past
|
||||||
|
*/
|
||||||
|
export function getValidFromDate(stringFromDate: string | undefined): Dayjs {
|
||||||
|
const now = dt().utc()
|
||||||
|
if (!stringFromDate) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
const toDate = dt(stringFromDate)
|
||||||
|
|
||||||
|
const yesterday = now.subtract(1, "day")
|
||||||
|
if (!toDate.isAfter(yesterday)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
return toDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid toDate from stringToDate making sure that it is after fromDate
|
||||||
|
*/
|
||||||
|
export function getValidToDate(
|
||||||
|
stringToDate: string | undefined,
|
||||||
|
fromDate: Dayjs | Date
|
||||||
|
): Dayjs {
|
||||||
|
const tomorrow = dt().utc().add(1, "day")
|
||||||
|
if (!stringToDate) {
|
||||||
|
return tomorrow
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDate = dt(stringToDate)
|
||||||
|
if (toDate.isAfter(fromDate)) {
|
||||||
|
return toDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return tomorrow
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
import { FamilyAndFriendsCodes } from "@/constants/booking"
|
||||||
|
|
||||||
|
import { HotelInfoCard } from "@/components/HotelReservation/SelectRate2/HotelInfoCard"
|
||||||
|
import { RoomsContainer } from "@/components/HotelReservation/SelectRate2/RoomsContainer"
|
||||||
|
|
||||||
|
import FnFNotAllowedAlert from "../FnFNotAllowedAlert/FnFNotAllowedAlert"
|
||||||
|
import AvailabilityError from "./AvailabilityError"
|
||||||
|
import Tracking from "./Tracking"
|
||||||
|
|
||||||
|
import type { RouterOutput } from "@scandic-hotels/trpc/client"
|
||||||
|
|
||||||
|
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
|
||||||
|
export default async function SelectRatePage({
|
||||||
|
booking,
|
||||||
|
hotelData,
|
||||||
|
}: {
|
||||||
|
hotelData: NonNullable<RouterOutput["hotel"]["get"]>
|
||||||
|
booking: SelectRateBooking
|
||||||
|
}) {
|
||||||
|
const bookingCode = booking.bookingCode
|
||||||
|
|
||||||
|
let isInValidFNF = false
|
||||||
|
if (bookingCode && FamilyAndFriendsCodes.includes(bookingCode)) {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
isInValidFNF = cookieStore.get("sc")?.value !== "1"
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HotelInfoCard hotel={hotelData.hotel} booking={booking} />
|
||||||
|
|
||||||
|
{isInValidFNF ? (
|
||||||
|
<FnFNotAllowedAlert />
|
||||||
|
) : (
|
||||||
|
<RoomsContainer
|
||||||
|
hotelType={hotelData.hotel.hotelType}
|
||||||
|
roomCategories={hotelData.roomCategories}
|
||||||
|
vat={hotelData.hotel.vat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tracking
|
||||||
|
hotelId={hotelData.hotel.id}
|
||||||
|
hotelName={hotelData.hotel.name}
|
||||||
|
country={hotelData.hotel.address.country}
|
||||||
|
city={hotelData.hotel.address.city}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AvailabilityError />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { dt } from "@scandic-hotels/common/dt"
|
import { type Dayjs, dt } from "@scandic-hotels/common/dt"
|
||||||
import {
|
import {
|
||||||
MaterialIcon,
|
MaterialIcon,
|
||||||
type MaterialIconSetIconProps,
|
type MaterialIconSetIconProps,
|
||||||
@@ -8,7 +8,7 @@ import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum"
|
|||||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts"
|
import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts"
|
||||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
import type { Package, Packages } from "@scandic-hotels/trpc/types/packages"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { type RoomPackageCodes } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { type RoomPackageCodes } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
@@ -38,7 +38,9 @@ export const invertedBedTypeMap: Record<ChildBedTypeEnum, string> = {
|
|||||||
[ChildBedTypeEnum.Unknown]: ChildBedMapEnum[ChildBedMapEnum.UNKNOWN],
|
[ChildBedTypeEnum.Unknown]: ChildBedMapEnum[ChildBedMapEnum.UNKNOWN],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumPackages(packages: Packages | null) {
|
export function sumPackages(
|
||||||
|
packages: Pick<Package, "localPrice">[] | undefined | null
|
||||||
|
) {
|
||||||
if (!packages || !packages.length) {
|
if (!packages || !packages.length) {
|
||||||
return {
|
return {
|
||||||
currency: undefined,
|
currency: undefined,
|
||||||
@@ -88,23 +90,23 @@ export function calculateVat(priceInclVat: number, vat: number) {
|
|||||||
|
|
||||||
export function getHotelAlertsForBookingDates(
|
export function getHotelAlertsForBookingDates(
|
||||||
specialAlerts: Zod.infer<typeof specialAlertsSchema>,
|
specialAlerts: Zod.infer<typeof specialAlertsSchema>,
|
||||||
fromDate: string,
|
fromDate: Date | Dayjs,
|
||||||
toDate: string
|
toDate: Date | Dayjs
|
||||||
) {
|
) {
|
||||||
return specialAlerts.filter((alert) => {
|
return specialAlerts.filter((alert) => {
|
||||||
if (alert.endDate && alert.startDate) {
|
if (alert.endDate && alert.startDate) {
|
||||||
const endDate = dt(alert.endDate)
|
const alertEndDate = dt(alert.endDate)
|
||||||
const startDate = dt(alert.startDate)
|
const alertStartDate = dt(alert.startDate)
|
||||||
|
|
||||||
const fromDateIsBetweenAlertDates = dt(fromDate).isBetween(
|
const fromDateIsBetweenAlertDates = dt(fromDate).isBetween(
|
||||||
startDate,
|
alertStartDate,
|
||||||
endDate,
|
alertEndDate,
|
||||||
"date",
|
"date",
|
||||||
"[]"
|
"[]"
|
||||||
)
|
)
|
||||||
const toDateIsBetweenAlertDates = dt(toDate).isBetween(
|
const toDateIsBetweenAlertDates = dt(toDate).isBetween(
|
||||||
startDate,
|
alertStartDate,
|
||||||
endDate,
|
alertEndDate,
|
||||||
"date",
|
"date",
|
||||||
"[]"
|
"[]"
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user