Merged in feat/sw-2874-move-select-rate (pull request #2750)
Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
"use client"
|
||||
import PriceDetailsModal from "@scandic-hotels/booking-flow/components/PriceDetailsModal"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
|
||||
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
|
||||
import { mapToPrice } from "./mapToPrice"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
@@ -2,13 +2,13 @@ import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { PackageTypeEnum } from "@scandic-hotels/trpc/enums/packages"
|
||||
import {
|
||||
type BreakfastPackage,
|
||||
breakfastPackageSchema,
|
||||
packageSchema,
|
||||
} from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { Room } from "@/types/stores/booking-confirmation"
|
||||
|
||||
export function mapToPrice(rooms: (Room | null)[], nights: number) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomDetailsSidePeek } from "@scandic-hotels/booking-flow/components/RoomDetailsSidePeek"
|
||||
import {
|
||||
changeOrCancelDateFormat,
|
||||
longDateFormat,
|
||||
@@ -16,7 +17,6 @@ import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
|
||||
import { CancellationRuleEnum } from "@/constants/booking"
|
||||
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
|
||||
|
||||
import RoomDetailsSidePeek from "@/components/SidePeeks/RoomDetailsSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const bedTypeSchema = z.object({
|
||||
bedType: z.object({
|
||||
description: z.string(),
|
||||
roomTypeCode: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
})
|
||||
export const bedTypeFormSchema = z.object({
|
||||
bedType: z.string(),
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomDetailsSidePeek } from "@scandic-hotels/booking-flow/components/RoomDetailsSidePeek"
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
@@ -13,7 +14,6 @@ import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import RoomDetailsSidePeek from "@/components/SidePeeks/RoomDetailsSidePeek"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type PropsWithChildren, useEffect, useRef } from "react"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { isBookingCodeRate } from "@scandic-hotels/booking-flow/components/SelectRate/RoomsContainer/RateSummary/utils"
|
||||
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"
|
||||
@@ -14,7 +15,6 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
|
||||
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import SignupPromoMobile from "@scandic-hotels/booking-flow/components/SignupPromoMobile"
|
||||
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SummaryUI from "../UI"
|
||||
import SummaryBottomSheet from "./BottomSheet"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./breakfast.module.css"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
|
||||
interface BreakfastProps {
|
||||
adults: number
|
||||
|
||||
@@ -4,6 +4,9 @@ import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import PriceDetailsModal from "@scandic-hotels/booking-flow/components/PriceDetailsModal"
|
||||
import { isBookingCodeRate } from "@scandic-hotels/booking-flow/components/SelectRate/RoomsContainer/RateSummary/utils"
|
||||
import SignupPromoDesktop from "@scandic-hotels/booking-flow/components/SignupPromoDesktop"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
@@ -15,9 +18,6 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { mapToPrice } from "./mapToPrice"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import { getSpecialRoomType } from "@/utils/specialRoomType"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import type { BreakfastPackages } from "@scandic-hotels/trpc/routers/hotels/output"
|
||||
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { Room } from "@scandic-hotels/trpc/types/room"
|
||||
import type {
|
||||
@@ -23,7 +24,6 @@ import type {
|
||||
Product,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
|
||||
import type {
|
||||
DetailsBooking,
|
||||
RoomRate,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
import PriceDetailsModal from "@scandic-hotels/booking-flow/components/PriceDetailsModal"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
|
||||
import { calculateTotalPrice, mapToPrice } from "./mapToPrice"
|
||||
|
||||
import styles from "./priceDetails.module.css"
|
||||
|
||||
@@ -11,10 +11,10 @@ import { getPriceType } from "../../utils/getPriceType"
|
||||
import { formatChildBedPreferences } from "../utils"
|
||||
|
||||
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
||||
import type { Room } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { Room as MyStayRoom } from "@/types/stores/my-stay"
|
||||
|
||||
interface MapRoomDetailsParams {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import BoldRow from "./Row/Bold"
|
||||
import RegularRow from "./Row/Regular"
|
||||
import Tbody from "./Tbody"
|
||||
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
|
||||
interface BreakfastProps {
|
||||
adults: number
|
||||
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined | null
|
||||
breakfastChildren: Omit<BreakfastPackage, "requestedPrice"> | null | undefined
|
||||
breakfastIncluded: boolean
|
||||
childrenInRoom: Child[] | undefined
|
||||
currency: string
|
||||
nights: number
|
||||
}
|
||||
|
||||
export default function Breakfast({
|
||||
adults,
|
||||
breakfast,
|
||||
breakfastChildren,
|
||||
breakfastIncluded,
|
||||
childrenInRoom = [],
|
||||
currency,
|
||||
nights,
|
||||
}: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfastBuffet = intl.formatMessage({
|
||||
defaultMessage: "Breakfast buffet",
|
||||
})
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
|
||||
},
|
||||
{ totalAdults: adults, totalBreakfasts: nights }
|
||||
)
|
||||
|
||||
let kidsMsg = ""
|
||||
if (childrenInRoom?.length) {
|
||||
kidsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
|
||||
},
|
||||
{
|
||||
totalChildren: childrenInRoom.length,
|
||||
totalBreakfasts: nights,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (breakfastIncluded) {
|
||||
const included = intl.formatMessage({ defaultMessage: "Included" })
|
||||
return (
|
||||
<Tbody>
|
||||
<RegularRow label={adultsMsg} value={included} />
|
||||
{childrenInRoom?.length ? (
|
||||
<RegularRow label={kidsMsg} value={included} />
|
||||
) : null}
|
||||
<BoldRow
|
||||
label={breakfastBuffet}
|
||||
value={formatPrice(intl, 0, currency)}
|
||||
/>
|
||||
</Tbody>
|
||||
)
|
||||
}
|
||||
|
||||
if (breakfast) {
|
||||
const adultPricePerNight = breakfast.localPrice.price * adults
|
||||
const breakfastAdultsPricePerNight = formatPrice(
|
||||
intl,
|
||||
adultPricePerNight,
|
||||
breakfast.localPrice.currency
|
||||
)
|
||||
|
||||
const { payingChildren, freeChildren } = childrenInRoom.reduce(
|
||||
(total, child) => {
|
||||
if (child.age >= 4) {
|
||||
total.payingChildren = total.payingChildren + 1
|
||||
} else {
|
||||
total.freeChildren = total.freeChildren + 1
|
||||
}
|
||||
return total
|
||||
},
|
||||
{ payingChildren: 0, freeChildren: 0 }
|
||||
)
|
||||
|
||||
const childrenPrice = breakfastChildren?.localPrice.price || 0
|
||||
const childrenPricePerNight = childrenPrice * payingChildren
|
||||
|
||||
const childCurrency =
|
||||
breakfastChildren?.localPrice.currency ?? breakfast.localPrice.currency
|
||||
|
||||
const breakfastChildrenPricePerNight = formatPrice(
|
||||
intl,
|
||||
childrenPricePerNight,
|
||||
childCurrency
|
||||
)
|
||||
|
||||
const totalAdultsPrice = adultPricePerNight * nights
|
||||
const totalChildrenPrice = childrenPricePerNight * nights
|
||||
const breakfastTotalPrice = formatPrice(
|
||||
intl,
|
||||
totalAdultsPrice + totalChildrenPrice,
|
||||
breakfast.localPrice.currency
|
||||
)
|
||||
|
||||
const freeChildrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
|
||||
},
|
||||
{
|
||||
totalChildren: freeChildren,
|
||||
totalBreakfasts: nights,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Tbody border>
|
||||
<RegularRow
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
|
||||
},
|
||||
{ totalAdults: adults, totalBreakfasts: nights }
|
||||
)}
|
||||
value={breakfastAdultsPricePerNight}
|
||||
/>
|
||||
{breakfastChildren ? (
|
||||
<RegularRow
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
|
||||
},
|
||||
{
|
||||
totalChildren: payingChildren,
|
||||
totalBreakfasts: nights,
|
||||
}
|
||||
)}
|
||||
value={breakfastChildrenPricePerNight}
|
||||
/>
|
||||
) : null}
|
||||
{breakfastChildren && freeChildren ? (
|
||||
<RegularRow
|
||||
label={`${freeChildrenMsg} (0-3)`}
|
||||
value={formatPrice(intl, 0, breakfast.localPrice.currency)}
|
||||
/>
|
||||
) : null}
|
||||
{childrenInRoom?.length && !breakfastChildren ? (
|
||||
<RegularRow
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
|
||||
},
|
||||
{
|
||||
totalChildren: childrenInRoom.length,
|
||||
totalBreakfasts: nights,
|
||||
}
|
||||
)}
|
||||
value={formatPrice(intl, 0, breakfast.localPrice.currency)}
|
||||
/>
|
||||
) : null}
|
||||
<BoldRow label={breakfastBuffet} value={breakfastTotalPrice} />
|
||||
</Tbody>
|
||||
)
|
||||
}
|
||||
|
||||
if (breakfast === false) {
|
||||
const noBreakfast = intl.formatMessage({
|
||||
defaultMessage: "No breakfast",
|
||||
})
|
||||
return (
|
||||
<Tbody>
|
||||
<BoldRow label={breakfastBuffet} value={noBreakfast} />
|
||||
</Tbody>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
|
||||
interface RowProps {
|
||||
label: string
|
||||
value: string
|
||||
regularValue?: string
|
||||
isDiscounted?: boolean
|
||||
}
|
||||
|
||||
export default function BoldRow({
|
||||
label,
|
||||
value,
|
||||
regularValue,
|
||||
isDiscounted = false,
|
||||
}: RowProps) {
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{label}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
{isDiscounted && regularValue ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<s className={styles.strikeThroughRate}>{regularValue}</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={cx({ [styles.discounted]: isDiscounted })}>
|
||||
{value}
|
||||
</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
|
||||
interface BookingCodeRowProps {
|
||||
bookingCode?: string
|
||||
isBreakfastIncluded?: boolean
|
||||
isCampaignRate?: boolean
|
||||
}
|
||||
|
||||
export default function BookingCodeRow({
|
||||
bookingCode,
|
||||
isBreakfastIncluded,
|
||||
isCampaignRate,
|
||||
}: BookingCodeRowProps) {
|
||||
if (!bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td colSpan={2} align="left">
|
||||
<BookingCodeChip
|
||||
bookingCode={bookingCode}
|
||||
isBreakfastIncluded={isBreakfastIncluded}
|
||||
isCampaign={isCampaignRate}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
interface TrProps {
|
||||
subtitle?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export default function HeaderRow({ subtitle, title }: TrProps) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{title}</span>
|
||||
</Typography>
|
||||
</th>
|
||||
</tr>
|
||||
{subtitle ? (
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{subtitle}</span>
|
||||
</Typography>
|
||||
</th>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
interface RowProps {
|
||||
allPricesIsDiscounted: boolean
|
||||
label: string
|
||||
price: Price
|
||||
}
|
||||
|
||||
export default function LargeRow({
|
||||
allPricesIsDiscounted,
|
||||
label,
|
||||
price,
|
||||
}: RowProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const totalPrice = formatPrice(
|
||||
intl,
|
||||
price.local.price,
|
||||
price.local.currency,
|
||||
price.local.additionalPrice,
|
||||
price.local.additionalPriceCurrency
|
||||
)
|
||||
const regularPrice = price.local.regularPrice
|
||||
? formatPrice(
|
||||
intl,
|
||||
price.local.regularPrice,
|
||||
price.local.currency,
|
||||
price.local.additionalPrice,
|
||||
price.local.additionalPriceCurrency
|
||||
)
|
||||
: null
|
||||
|
||||
const isDiscounted =
|
||||
allPricesIsDiscounted ||
|
||||
(price.local.regularPrice !== undefined &&
|
||||
price.local.regularPrice > price.local.price)
|
||||
return (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<span>{label}</span>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
{isDiscounted && regularPrice ? (
|
||||
<>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>{regularPrice}</s>
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<span className={cx({ [styles.discounted]: isDiscounted })}>
|
||||
{totalPrice}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import RegularRow from "../Regular"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
interface BedTypeRowProps {
|
||||
bedType: BedTypeSchema | undefined
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export default function BedTypeRow({
|
||||
bedType,
|
||||
currency = "",
|
||||
}: BedTypeRowProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!bedType) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RegularRow
|
||||
label={bedType.description}
|
||||
value={formatPrice(intl, 0, currency)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import BoldRow from "../Bold"
|
||||
import RegularRow from "../Regular"
|
||||
import BedTypeRow from "./BedType"
|
||||
import PackagesRow from "./Packages"
|
||||
|
||||
import type { SharedPriceRowProps } from "./price"
|
||||
|
||||
export interface CorporateChequePriceType {
|
||||
corporateCheque?: {
|
||||
additionalPricePerStay?: number
|
||||
currency?: CurrencyEnum
|
||||
numberOfCheques: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CorporateChequePriceProps extends SharedPriceRowProps {
|
||||
currency: string
|
||||
price: CorporateChequePriceType["corporateCheque"]
|
||||
}
|
||||
|
||||
export default function CorporateChequePrice({
|
||||
bedType,
|
||||
currency,
|
||||
nights,
|
||||
packages,
|
||||
price,
|
||||
}: CorporateChequePriceProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!price) {
|
||||
return null
|
||||
}
|
||||
|
||||
const averagePriceTitle = intl.formatMessage({
|
||||
defaultMessage: "Average price per night",
|
||||
})
|
||||
|
||||
const additionalPricePerStay = price.additionalPricePerStay
|
||||
|
||||
const averageChequesPerNight = price.numberOfCheques / nights
|
||||
const averageAdditionalPricePerNight = additionalPricePerStay
|
||||
? Math.ceil(additionalPricePerStay / nights)
|
||||
: null
|
||||
|
||||
const additionalCurrency = price.currency ?? currency
|
||||
let averagePricePerNight = `${averageChequesPerNight} ${CurrencyEnum.CC}`
|
||||
if (averageAdditionalPricePerNight) {
|
||||
averagePricePerNight = `${averagePricePerNight} + ${averageAdditionalPricePerNight} ${additionalCurrency}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoldRow
|
||||
label={intl.formatMessage({ defaultMessage: "Room charge" })}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
price.numberOfCheques,
|
||||
CurrencyEnum.CC,
|
||||
additionalPricePerStay,
|
||||
additionalCurrency
|
||||
)}
|
||||
/>
|
||||
{nights > 1 ? (
|
||||
<RegularRow label={averagePriceTitle} value={averagePricePerNight} />
|
||||
) : null}
|
||||
<BedTypeRow bedType={bedType} currency={currency} />
|
||||
<PackagesRow packages={packages} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import { getFeatureDescription } from "@/components/HotelReservation/utils/getRoomFeatureDescription"
|
||||
|
||||
import RegularRow from "../Regular"
|
||||
|
||||
import type { Packages as PackagesType } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
interface PackagesProps {
|
||||
packages: PackagesType | null
|
||||
}
|
||||
|
||||
export default function PackagesRow({ packages }: PackagesProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!packages || !packages.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return packages?.map((pkg) => (
|
||||
<RegularRow
|
||||
key={pkg.code}
|
||||
label={getFeatureDescription(pkg.code, pkg.description, intl)}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
+pkg.localPrice.totalPrice,
|
||||
pkg.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import BoldRow from "../Bold"
|
||||
import RegularRow from "../Regular"
|
||||
import BedTypeRow from "./BedType"
|
||||
import PackagesRow from "./Packages"
|
||||
|
||||
import type { SharedPriceRowProps } from "./price"
|
||||
|
||||
export interface RedemptionPriceType {
|
||||
redemption?: {
|
||||
additionalPricePerStay?: number
|
||||
currency?: CurrencyEnum
|
||||
pointsPerNight: number
|
||||
pointsPerStay: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RedemptionPriceProps extends SharedPriceRowProps {
|
||||
currency: string
|
||||
nights: number
|
||||
price: RedemptionPriceType["redemption"]
|
||||
}
|
||||
|
||||
export default function RedemptionPrice({
|
||||
bedType,
|
||||
currency,
|
||||
nights,
|
||||
packages,
|
||||
price,
|
||||
}: RedemptionPriceProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!price) {
|
||||
return null
|
||||
}
|
||||
|
||||
const averagePriceTitle = intl.formatMessage({
|
||||
defaultMessage: "Average price per night",
|
||||
})
|
||||
|
||||
const additionalPricePerStay = price.additionalPricePerStay
|
||||
|
||||
const averageAdditionalPricePerNight = additionalPricePerStay
|
||||
? Math.ceil(additionalPricePerStay / nights)
|
||||
: null
|
||||
|
||||
const additionalCurrency = price.currency ?? currency
|
||||
let averagePricePerNight = `${price.pointsPerNight} ${CurrencyEnum.POINTS}`
|
||||
if (averageAdditionalPricePerNight) {
|
||||
averagePricePerNight = `${averagePricePerNight} + ${averageAdditionalPricePerNight} ${additionalCurrency}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoldRow
|
||||
label={intl.formatMessage({ defaultMessage: "Room charge" })}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
price.pointsPerStay,
|
||||
CurrencyEnum.POINTS,
|
||||
additionalPricePerStay,
|
||||
additionalCurrency
|
||||
)}
|
||||
/>
|
||||
{nights > 1 ? (
|
||||
<RegularRow label={averagePriceTitle} value={averagePricePerNight} />
|
||||
) : null}
|
||||
<BedTypeRow bedType={bedType} currency={currency} />
|
||||
<PackagesRow packages={packages} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import BoldRow from "../Bold"
|
||||
import RegularRow from "../Regular"
|
||||
import BedTypeRow from "./BedType"
|
||||
import PackagesRow from "./Packages"
|
||||
|
||||
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import type { SharedPriceRowProps } from "./price"
|
||||
|
||||
export interface RegularPriceType {
|
||||
regular?: {
|
||||
currency: CurrencyEnum
|
||||
pricePerNight: number
|
||||
pricePerStay: number
|
||||
regularPricePerStay: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RegularPriceProps extends SharedPriceRowProps {
|
||||
isMemberRate: boolean
|
||||
price: RegularPriceType["regular"]
|
||||
}
|
||||
|
||||
export default function RegularPrice({
|
||||
bedType,
|
||||
isMemberRate,
|
||||
nights,
|
||||
packages,
|
||||
price,
|
||||
}: RegularPriceProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!price) {
|
||||
return null
|
||||
}
|
||||
|
||||
const averagePriceTitle = intl.formatMessage({
|
||||
defaultMessage: "Average price per night",
|
||||
})
|
||||
|
||||
const avgeragePricePerNight = formatPrice(
|
||||
intl,
|
||||
price.pricePerNight,
|
||||
price.currency
|
||||
)
|
||||
|
||||
const roomCharge = formatPrice(intl, price.pricePerStay, price.currency)
|
||||
|
||||
const regularPriceIsHigherThanPrice =
|
||||
price.regularPricePerStay > price.pricePerStay
|
||||
let regularCharge = undefined
|
||||
if (regularPriceIsHigherThanPrice) {
|
||||
regularCharge = formatPrice(intl, price.regularPricePerStay, price.currency)
|
||||
}
|
||||
const isDiscounted = isMemberRate || regularPriceIsHigherThanPrice
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoldRow
|
||||
label={intl.formatMessage({ defaultMessage: "Room charge" })}
|
||||
value={roomCharge}
|
||||
regularValue={regularCharge}
|
||||
isDiscounted={isDiscounted}
|
||||
/>
|
||||
{nights > 1 ? (
|
||||
<RegularRow label={averagePriceTitle} value={avgeragePricePerNight} />
|
||||
) : null}
|
||||
<BedTypeRow bedType={bedType} currency={price.currency} />
|
||||
<PackagesRow packages={packages} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import BoldRow from "../Bold"
|
||||
import RegularRow from "../Regular"
|
||||
import BedTypeRow from "./BedType"
|
||||
import PackagesRow from "./Packages"
|
||||
|
||||
import type { SharedPriceRowProps } from "./price"
|
||||
|
||||
export interface VoucherPriceType {
|
||||
voucher?: {
|
||||
numberOfVouchers: number
|
||||
}
|
||||
}
|
||||
|
||||
interface VoucherPriceProps extends SharedPriceRowProps {
|
||||
currency: string
|
||||
nights: number
|
||||
price: VoucherPriceType["voucher"]
|
||||
}
|
||||
|
||||
export default function VoucherPrice({
|
||||
bedType,
|
||||
currency,
|
||||
nights,
|
||||
packages,
|
||||
price,
|
||||
}: VoucherPriceProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!price) {
|
||||
return null
|
||||
}
|
||||
|
||||
const averagePriceTitle = intl.formatMessage({
|
||||
defaultMessage: "Average price per night",
|
||||
})
|
||||
|
||||
const averagePricePerNight = formatPrice(
|
||||
intl,
|
||||
price.numberOfVouchers / nights,
|
||||
CurrencyEnum.Voucher
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoldRow
|
||||
label={intl.formatMessage({ defaultMessage: "Room charge" })}
|
||||
value={formatPrice(intl, price.numberOfVouchers, CurrencyEnum.Voucher)}
|
||||
/>
|
||||
{nights > 1 ? (
|
||||
<RegularRow label={averagePriceTitle} value={averagePricePerNight} />
|
||||
) : null}
|
||||
<BedTypeRow bedType={bedType} currency={currency} />
|
||||
<PackagesRow packages={packages} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export interface SharedPriceRowProps {
|
||||
bedType: BedTypeSchema | undefined
|
||||
nights: number
|
||||
packages: Packages | null
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
|
||||
interface RowProps {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export default function RegularRow({ label, value }: RowProps) {
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>{label}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>{value}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { calculateVat } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import RegularRow from "./Regular"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
interface VatProps {
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
}
|
||||
|
||||
const noVatCurrencies = [
|
||||
CurrencyEnum.CC,
|
||||
CurrencyEnum.POINTS,
|
||||
CurrencyEnum.Voucher,
|
||||
CurrencyEnum.Unknown,
|
||||
]
|
||||
|
||||
export default function VatRow({ totalPrice, vat }: VatProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (noVatCurrencies.includes(totalPrice.local.currency)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { vatAmount, priceExclVat } = calculateVat(totalPrice.local.price, vat)
|
||||
|
||||
return (
|
||||
<>
|
||||
<RegularRow
|
||||
label={intl.formatMessage({ defaultMessage: "Price excluding VAT" })}
|
||||
value={formatPrice(intl, priceExclVat, totalPrice.local.currency)}
|
||||
/>
|
||||
<RegularRow
|
||||
label={intl.formatMessage({ defaultMessage: "VAT {vat}%" }, { vat })}
|
||||
value={formatPrice(intl, vatAmount, totalPrice.local.currency)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.price {
|
||||
text-align: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
|
||||
.price .strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type TbodyProps, tbodyVariants } from "./variants"
|
||||
|
||||
export default function Tbody({ border, children }: TbodyProps) {
|
||||
const classNames = tbodyVariants({ border })
|
||||
return <tbody className={classNames}>{children}</tbody>
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.tbody {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tbody:has(tr > th) {
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.tbody:has(tr > th):not(:first-of-type),
|
||||
.border {
|
||||
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.tbody:not(:last-child) {
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.border {
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import styles from "./tbody.module.css"
|
||||
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
export const tbodyVariants = cva(styles.tbody, {
|
||||
variants: {
|
||||
border: {
|
||||
true: styles.border,
|
||||
},
|
||||
},
|
||||
defaultVariants: {},
|
||||
})
|
||||
|
||||
export interface TbodyProps
|
||||
extends PropsWithChildren,
|
||||
VariantProps<typeof tbodyVariants> {}
|
||||
@@ -1,228 +0,0 @@
|
||||
"use client"
|
||||
import { Fragment } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import BookingCodeRow from "./Row/BookingCode"
|
||||
import HeaderRow from "./Row/Header"
|
||||
import LargeRow from "./Row/Large"
|
||||
import CorporateChequePrice, {
|
||||
type CorporateChequePriceType,
|
||||
} from "./Row/Price/CorporateCheque"
|
||||
import RedemptionPrice, {
|
||||
type RedemptionPriceType,
|
||||
} from "./Row/Price/Redemption"
|
||||
import RegularPrice, { type RegularPriceType } from "./Row/Price/Regular"
|
||||
import VoucherPrice, { type VoucherPriceType } from "./Row/Price/Voucher"
|
||||
import VatRow from "./Row/Vat"
|
||||
import Breakfast from "./Breakfast"
|
||||
import Tbody from "./Tbody"
|
||||
|
||||
import styles from "./priceDetailsTable.module.css"
|
||||
|
||||
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { RateDefinition } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
type RoomPrice =
|
||||
| CorporateChequePriceType
|
||||
| RegularPriceType
|
||||
| RedemptionPriceType
|
||||
| VoucherPriceType
|
||||
|
||||
export interface Room {
|
||||
adults: number
|
||||
bedType: BedTypeSchema | undefined
|
||||
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined | null
|
||||
breakfastChildren?: Omit<BreakfastPackage, "requestedPrice"> | null
|
||||
breakfastIncluded: boolean
|
||||
childrenInRoom: Child[] | undefined
|
||||
packages: Packages | null
|
||||
price: RoomPrice
|
||||
rateDefinition: Pick<RateDefinition, "isMemberRate">
|
||||
roomType: string
|
||||
}
|
||||
|
||||
export interface PriceDetailsTableProps {
|
||||
bookingCode?: string
|
||||
fromDate: string
|
||||
isCampaignRate?: boolean
|
||||
rooms: Room[]
|
||||
toDate: string
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
defaultCurrency: CurrencyEnum
|
||||
}
|
||||
|
||||
export default function PriceDetailsTable({
|
||||
bookingCode,
|
||||
fromDate,
|
||||
isCampaignRate,
|
||||
rooms,
|
||||
toDate,
|
||||
totalPrice,
|
||||
vat,
|
||||
defaultCurrency,
|
||||
}: PriceDetailsTableProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const nights = dt(toDate).diff(fromDate, "days")
|
||||
const nightsMsg = intl.formatMessage(
|
||||
{ defaultMessage: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: nights }
|
||||
)
|
||||
|
||||
const arrival = dt(fromDate).locale(lang).format(longDateFormat[lang])
|
||||
const departue = dt(toDate).locale(lang).format(longDateFormat[lang])
|
||||
const duration = ` ${arrival} - ${departue} (${nightsMsg})`
|
||||
|
||||
const isAllBreakfastIncluded = rooms.every((room) => room.breakfastIncluded)
|
||||
|
||||
const allPricesIsDiscounted = rooms.every((room) => {
|
||||
if (!("regular" in room.price)) {
|
||||
return false
|
||||
}
|
||||
if (room.rateDefinition.isMemberRate) {
|
||||
return true
|
||||
}
|
||||
if (!room.price.regular) {
|
||||
return false
|
||||
}
|
||||
|
||||
return room.price.regular.pricePerStay > room.price.regular.pricePerStay
|
||||
})
|
||||
|
||||
return (
|
||||
<table className={styles.priceDetailsTable}>
|
||||
{rooms.map((room, idx) => {
|
||||
let currency = ""
|
||||
let chequePrice: CorporateChequePriceType["corporateCheque"] | undefined
|
||||
if ("corporateCheque" in room.price && room.price.corporateCheque) {
|
||||
chequePrice = room.price.corporateCheque
|
||||
|
||||
if (room.price.corporateCheque.currency) {
|
||||
currency = room.price.corporateCheque.currency
|
||||
}
|
||||
}
|
||||
|
||||
let isMemberRate = false
|
||||
let price: RegularPriceType["regular"] | undefined
|
||||
if ("regular" in room.price && room.price.regular) {
|
||||
price = room.price.regular
|
||||
currency = room.price.regular.currency
|
||||
isMemberRate = room.rateDefinition.isMemberRate
|
||||
}
|
||||
|
||||
let redemptionPrice: RedemptionPriceType["redemption"] | undefined
|
||||
if ("redemption" in room.price && room.price.redemption) {
|
||||
redemptionPrice = room.price.redemption
|
||||
|
||||
if (room.price.redemption.currency) {
|
||||
currency = room.price.redemption.currency
|
||||
}
|
||||
}
|
||||
|
||||
let voucherPrice: VoucherPriceType["voucher"] | undefined
|
||||
if ("voucher" in room.price && room.price.voucher) {
|
||||
voucherPrice = room.price.voucher
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
currency = defaultCurrency
|
||||
}
|
||||
|
||||
if (!price && !voucherPrice && !chequePrice && !redemptionPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<Tbody>
|
||||
{rooms.length > 1 && (
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{ defaultMessage: "Room {roomIndex}" },
|
||||
{ roomIndex: idx + 1 }
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
<HeaderRow title={room.roomType} subtitle={duration} />
|
||||
<RegularPrice
|
||||
bedType={room.bedType}
|
||||
packages={room.packages}
|
||||
isMemberRate={isMemberRate}
|
||||
nights={nights}
|
||||
price={price}
|
||||
/>
|
||||
<CorporateChequePrice
|
||||
bedType={room.bedType}
|
||||
currency={currency}
|
||||
nights={nights}
|
||||
packages={room.packages}
|
||||
price={chequePrice}
|
||||
/>
|
||||
<RedemptionPrice
|
||||
bedType={room.bedType}
|
||||
currency={currency}
|
||||
nights={nights}
|
||||
packages={room.packages}
|
||||
price={redemptionPrice}
|
||||
/>
|
||||
<VoucherPrice
|
||||
bedType={room.bedType}
|
||||
currency={currency}
|
||||
nights={nights}
|
||||
packages={room.packages}
|
||||
price={voucherPrice}
|
||||
/>
|
||||
</Tbody>
|
||||
|
||||
<Breakfast
|
||||
adults={room.adults}
|
||||
breakfast={room.breakfast}
|
||||
breakfastChildren={room.breakfastChildren}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
childrenInRoom={room.childrenInRoom}
|
||||
currency={currency}
|
||||
nights={nights}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<Tbody>
|
||||
<HeaderRow title={intl.formatMessage({ defaultMessage: "Total" })} />
|
||||
|
||||
<VatRow totalPrice={totalPrice} vat={vat} />
|
||||
|
||||
<LargeRow
|
||||
allPricesIsDiscounted={allPricesIsDiscounted}
|
||||
label={intl.formatMessage({ defaultMessage: "Price including VAT" })}
|
||||
price={totalPrice}
|
||||
/>
|
||||
|
||||
<BookingCodeRow
|
||||
isCampaignRate={isCampaignRate}
|
||||
isBreakfastIncluded={isAllBreakfastIncluded}
|
||||
bookingCode={bookingCode}
|
||||
/>
|
||||
</Tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.priceDetailsTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.priceDetailsTable {
|
||||
min-width: 512px;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"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 Modal from "@scandic-hotels/design-system/Modal"
|
||||
|
||||
import PriceDetailsTable, {
|
||||
type PriceDetailsTableProps,
|
||||
} from "./PriceDetailsTable"
|
||||
|
||||
function Trigger({ title }: { title: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{title}
|
||||
<MaterialIcon icon="chevron_right" color="CurrentColor" size={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PriceDetailsModal(props: PriceDetailsTableProps) {
|
||||
const intl = useIntl()
|
||||
const title = intl.formatMessage({ defaultMessage: "Price details" })
|
||||
return (
|
||||
<Modal title={title} trigger={<Trigger title={title} />}>
|
||||
<PriceDetailsTable {...props} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import 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 "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import type { SelectedRate } from "@scandic-hotels/booking-flow/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
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
"use client"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 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 "@scandic-hotels/booking-flow/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,
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
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 Modal from "@scandic-hotels/design-system/Modal"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"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 { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import type {
|
||||
Rate,
|
||||
Room,
|
||||
} from "@scandic-hotels/booking-flow/types/components/selectRate/selectRate"
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type {
|
||||
Rate,
|
||||
Room as SelectRateRoom,
|
||||
} from "@scandic-hotels/booking-flow/types/components/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[]
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useTransition } from "react"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
||||
|
||||
import type { Rate } from "@scandic-hotels/booking-flow/types/components/selectRate/selectRate"
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type {
|
||||
Product,
|
||||
RedemptionProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
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
|
||||
): 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Chip from "@scandic-hotels/design-system/Chip"
|
||||
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 { 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}`
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 { 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
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
.hotelAlert {
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
||||
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 { 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
|
||||
|
||||
export function RemoveBookingCodeButton() {
|
||||
const {
|
||||
input: { bookingCode },
|
||||
actions: { removeBookingCode },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (!bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingCodeChip
|
||||
bookingCode={bookingCode}
|
||||
filledIcon
|
||||
withCloseButton={true}
|
||||
withText={false}
|
||||
onClose={() => {
|
||||
removeBookingCode()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.additionalInformation {
|
||||
color: var(--Text-Tertiary);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
}
|
||||
|
||||
.additionalInformationPrice {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"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
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export type FormValues = {
|
||||
selectedPackages: PackageEnum[]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 { 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,
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"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 "@scandic-hotels/booking-flow/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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.breakfastMessage {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: 1;
|
||||
min-width: 5%;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import CampaignRateCard from "@scandic-hotels/design-system/CampaignRateCard"
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "@scandic-hotels/booking-flow/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
|
||||
|
||||
const campaignStandardLabel =
|
||||
product.rateDefinition?.title || standardPriceMsg
|
||||
|
||||
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: campaignMemberLabel,
|
||||
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
|
||||
: campaignStandardLabel,
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
|
||||
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { Package } from "@scandic-hotels/booking-flow/contexts/SelectRate/types"
|
||||
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
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, bookingCode })
|
||||
}
|
||||
|
||||
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:
|
||||
"{numberOfVouchers, plural, one {Voucher} other {Vouchers}}",
|
||||
},
|
||||
{
|
||||
numberOfVouchers: numberOfVouchers,
|
||||
}
|
||||
)
|
||||
|
||||
const { price, currency } = packagesSum.price
|
||||
? {
|
||||
price: `${numberOfVouchers} ${voucherMsg} + ${packagesSum.price}`,
|
||||
currency: packagesSum.currency ?? "",
|
||||
}
|
||||
: {
|
||||
price: `${numberOfVouchers} ${voucherMsg}`,
|
||||
currency: "",
|
||||
}
|
||||
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: 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[] }[]
|
||||
@@ -1,132 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
|
||||
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "@scandic-hotels/booking-flow/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 {
|
||||
input: { bookingCode },
|
||||
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,
|
||||
bookingCode: bookingCode,
|
||||
})
|
||||
}}
|
||||
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",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
import RegularRateCard from "@scandic-hotels/design-system/RegularRateCard"
|
||||
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "@scandic-hotels/booking-flow/contexts/SelectRate/types"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
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,
|
||||
bookingCode: product.bookingCode,
|
||||
})
|
||||
}}
|
||||
isMemberRateActive={isMemberRateActive}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomIndex + 1}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
rateTermDetails={rateTermDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
|
||||
import { BreakfastMessage } from "./BreakfastMessage"
|
||||
import Campaign from "./Campaign"
|
||||
import Code from "./Code"
|
||||
import Redemptions from "./Redemptions"
|
||||
import { RegularRate } from "./Regular"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "@scandic-hotels/booking-flow/contexts/SelectRate/types"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { SelectedRate } from "@scandic-hotels/booking-flow/types/stores/rates"
|
||||
import type {
|
||||
CorporateChequeProduct,
|
||||
PriceProduct,
|
||||
VoucherProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.imageContainer {
|
||||
margin: 0 calc(-1 * var(--Spacing-x2));
|
||||
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);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client"
|
||||
import { memo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/contexts/SelectRate/SelectRateContext"
|
||||
import { IconForFeatureCode } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
|
||||
|
||||
import RoomDetailsSidePeek from "@/components/SidePeeks/RoomDetailsSidePeek"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
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 || [])
|
||||
const { hotel } = useSelectRateContext()
|
||||
const room = getHotelRoom(hotel?.data?.roomCategories ?? [], roomTypeCode)
|
||||
const intl = useIntl()
|
||||
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 && room && (
|
||||
<RoomDetailsSidePeek
|
||||
hotelId={hotelId}
|
||||
room={room}
|
||||
roomTypeCode={roomTypeCode}
|
||||
triggerLabel={intl.formatMessage({
|
||||
defaultMessage: "View room details",
|
||||
})}
|
||||
buttonVariant="secondary"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 { AvailabilityWithRoomInfo } from "@scandic-hotels/booking-flow/contexts/SelectRate/types"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
export const listItemVariants = cva(styles.listItem, {
|
||||
variants: {
|
||||
availability: {
|
||||
noAvailability: styles.noAvailability,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { RoomCardSkeleton } from "@scandic-hotels/booking-flow/components/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>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client"
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSelectRateContext } from "@scandic-hotels/booking-flow/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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.content {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user