Merged in feat/sw-2874-move-select-rate (pull request #2750)
Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import React from "react"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
type ErrorBoundaryState = { hasError: boolean; error?: Error }
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
logger.error("ErrorBoundary caught an error:", error, errorInfo)
|
||||
Sentry.captureException(error, { extra: { errorInfo } })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const hasFallback = !!this.props.fallback
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasFallback && this.props.fallback}
|
||||
{!hasFallback && <h2>Something went wrong.</h2>}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<button onClick={() => this.setState({ hasError: false })}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Hotel,
|
||||
Restaurant,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
enum SidePeekEnum {
|
||||
hotelDetails = "hotel-detail-side-peek",
|
||||
@@ -23,7 +24,7 @@ interface HotelDetailsSidePeekProps {
|
||||
hotel: Hotel & { url: string | null }
|
||||
restaurants: Restaurant[]
|
||||
additionalHotelData: AdditionalData | undefined
|
||||
triggerLabel: string
|
||||
triggerLabel: ReactNode
|
||||
buttonVariant: "primary" | "secondary"
|
||||
wrapping?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"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 { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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/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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import RegularRow from "../Regular"
|
||||
|
||||
interface BedTypeRowProps {
|
||||
bedType:
|
||||
| {
|
||||
description: string
|
||||
}
|
||||
| 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
import { type IntlShape, useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
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
|
||||
)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
// TODO this might be duplicated, check if we can reuse
|
||||
function getFeatureDescription(
|
||||
code: string,
|
||||
description: string,
|
||||
intl: IntlShape
|
||||
): string {
|
||||
const roomFeatureDescriptions: Record<string, string> = {
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Accessible room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Allergy-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.PET_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Pet-friendly room",
|
||||
}),
|
||||
}
|
||||
|
||||
return roomFeatureDescriptions[code] ?? description
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export interface SharedPriceRowProps {
|
||||
bedType:
|
||||
| {
|
||||
description: string
|
||||
type: string
|
||||
roomTypeCode: string
|
||||
}
|
||||
| undefined
|
||||
nights: number
|
||||
packages: Packages | null
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
|
||||
import { calculateVat } from "../../../../utils/SelectRate"
|
||||
import RegularRow from "./Regular"
|
||||
|
||||
import type { Price } from "../../../../types/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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type TbodyProps, tbodyVariants } from "./variants"
|
||||
|
||||
export default function Tbody({ border, children }: TbodyProps) {
|
||||
const classNames = tbodyVariants({ border })
|
||||
return <tbody className={classNames}>{children}</tbody>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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> {}
|
||||
@@ -0,0 +1,232 @@
|
||||
"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 { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
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 { Price } from "../../../types/price"
|
||||
|
||||
type RoomPrice =
|
||||
| CorporateChequePriceType
|
||||
| RegularPriceType
|
||||
| RedemptionPriceType
|
||||
| VoucherPriceType
|
||||
|
||||
export interface Room {
|
||||
adults: number
|
||||
bedType:
|
||||
| {
|
||||
description: string
|
||||
type: string
|
||||
roomTypeCode: string
|
||||
}
|
||||
| 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.priceDetailsTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.priceDetailsTable {
|
||||
min-width: 512px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { FacilityIcon } from "@scandic-hotels/design-system/Icons/FacilityIcon"
|
||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BED_TYPE_ICONS, type BedTypes } from "../../../misc/bedTypeIcons"
|
||||
|
||||
import styles from "./roomSidePeekContent.module.css"
|
||||
|
||||
import type { ApiImage, Room } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface RoomSidePeekContentProps {
|
||||
room: Room
|
||||
}
|
||||
|
||||
export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const roomSize = room.roomSize
|
||||
const totalOccupancy = room.totalOccupancy
|
||||
const roomDescription = room.descriptions.medium
|
||||
const galleryImages = mapApiImagesToGalleryImages(room.images)
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.mainContent}>
|
||||
{totalOccupancy && (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Max. {max, plural, one {{range} guest} other {{range} guests}}",
|
||||
},
|
||||
{
|
||||
max: totalOccupancy.max,
|
||||
range: totalOccupancy.range,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
{roomSize && (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{roomSize.min === roomSize.max
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{roomSize} m²",
|
||||
},
|
||||
{
|
||||
roomSize: roomSize.min,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{roomSizeMin}–{roomSizeMax} m²",
|
||||
},
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery images={galleryImages} title={room.name} height={280} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room amenities",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<ul className={styles.facilityList}>
|
||||
{[...room.roomFacilities]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((facility) => {
|
||||
return (
|
||||
<li key={facility.name}>
|
||||
<FacilityIcon
|
||||
name={facility.icon}
|
||||
size={24}
|
||||
color="Icon/Default"
|
||||
/>
|
||||
<span>
|
||||
{facility.availableInAllRooms
|
||||
? facility.name
|
||||
: intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{facility} (available in some rooms)",
|
||||
},
|
||||
{
|
||||
facility: facility.name,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Bed options",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Subject to availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<ul className={styles.bedOptions}>
|
||||
{room.roomTypes.map((roomType) => {
|
||||
const description =
|
||||
roomType.description || roomType.mainBed.description
|
||||
const MainBedIcon =
|
||||
BED_TYPE_ICONS[roomType.mainBed.type as BedTypes]
|
||||
const ExtraBedIcon = roomType.fixedExtraBed
|
||||
? BED_TYPE_ICONS[roomType.fixedExtraBed.type as BedTypes]
|
||||
: null
|
||||
return (
|
||||
<li key={roomType.code}>
|
||||
{MainBedIcon ? <MainBedIcon height={24} width={24} /> : null}
|
||||
{ExtraBedIcon ? <ExtraBedIcon height={24} width={30} /> : null}
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{description}</span>
|
||||
</Typography>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.listContainer}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "About the hotel",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{roomDescription}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mapApiImagesToGalleryImages(apiImages: ApiImage[]) {
|
||||
return apiImages.map((apiImage) => ({
|
||||
src: apiImage.imageSizes.medium,
|
||||
alt:
|
||||
apiImage.metaData.altText ||
|
||||
apiImage.metaData.altText_En ||
|
||||
apiImage.metaData.title ||
|
||||
apiImage.metaData.title_En,
|
||||
caption: apiImage.metaData.title || apiImage.metaData.title_En,
|
||||
smallSrc: apiImage.imageSizes.small,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
margin-bottom: calc(
|
||||
var(--Spacing-x4) * 2 + 80px
|
||||
); /* Creates space between the wrapper and buttonContainer */
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.mainContent,
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
column-count: 2;
|
||||
column-gap: var(--Spacing-x2);
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
.facilityList li > span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.facilityList li {
|
||||
display: flex !important; /* Overrides the display none from grids.stackable on Hotel Page */
|
||||
gap: var(--Spacing-x1);
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.bedOptions {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.bedOptions li {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.facilityList li svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { DialogTrigger } from "react-aria-components"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
|
||||
|
||||
import { useTrackingContext } from "../../trackingContext"
|
||||
import { RoomSidePeekContent } from "./RoomSidePeekContent"
|
||||
|
||||
import type { Room } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
enum SidePeekEnum {
|
||||
roomDetails = "room-detail-side-peek",
|
||||
}
|
||||
|
||||
interface RoomDetailsSidePeekProps {
|
||||
hotelId: string
|
||||
room: Room
|
||||
triggerLabel: string
|
||||
roomTypeCode?: string
|
||||
buttonVariant?: "primary" | "secondary"
|
||||
wrapping?: boolean
|
||||
}
|
||||
|
||||
const buttonPropsMap: Record<
|
||||
NonNullable<RoomDetailsSidePeekProps["buttonVariant"]>,
|
||||
Pick<
|
||||
React.ComponentProps<typeof Button>,
|
||||
"variant" | "color" | "size" | "typography"
|
||||
>
|
||||
> = {
|
||||
primary: {
|
||||
variant: "Text",
|
||||
color: "Primary",
|
||||
size: "Medium",
|
||||
typography: "Body/Paragraph/mdBold",
|
||||
},
|
||||
secondary: {
|
||||
variant: "Text",
|
||||
color: "Inverted",
|
||||
size: "Small",
|
||||
typography: "Body/Supporting text (caption)/smBold",
|
||||
},
|
||||
}
|
||||
|
||||
export function RoomDetailsSidePeek({
|
||||
hotelId,
|
||||
room,
|
||||
roomTypeCode,
|
||||
triggerLabel,
|
||||
wrapping = true,
|
||||
buttonVariant: variant = "primary",
|
||||
}: RoomDetailsSidePeekProps) {
|
||||
const tracking = useTrackingContext()
|
||||
const buttonProps = buttonPropsMap[variant]
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
wrapping={wrapping}
|
||||
onPress={() =>
|
||||
tracking.trackOpenSidePeek({
|
||||
name: SidePeekEnum.roomDetails,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
includePathname: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{triggerLabel}
|
||||
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
||||
</Button>
|
||||
|
||||
<SidePeekSelfControlled title={room.name}>
|
||||
<RoomSidePeekContent room={room} />
|
||||
</SidePeekSelfControlled>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
export function AmenitiesSidePeekLabel() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See all amenities",
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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 { useIsLoggedIn } from "../../../../hooks/useIsLoggedIn"
|
||||
import SignupPromoDesktop from "../../../SignupPromo/Desktop"
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import type { useSelectRateContext } from "../../../../contexts/SelectRate/SelectRateContext"
|
||||
import type { SelectedRate } from "../../../../contexts/SelectRate/types"
|
||||
|
||||
export function DesktopSummary({
|
||||
input,
|
||||
selectedRates,
|
||||
isSubmitting,
|
||||
bookingCode,
|
||||
}: {
|
||||
selectedRates: ReturnType<typeof useSelectRateContext>["selectedRates"]
|
||||
isSubmitting: boolean
|
||||
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||
bookingCode: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
|
||||
if (!selectedRates.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasMemberRates = selectedRates.rates.some(
|
||||
(rate) => rate && "member" in rate && rate.member
|
||||
)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const totalNights = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: input.nights }
|
||||
)
|
||||
const totalAdults = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{
|
||||
totalAdults:
|
||||
input.data?.booking.rooms.reduce((acc, room) => acc + room.adults, 0) ??
|
||||
0,
|
||||
}
|
||||
)
|
||||
const childrenInOneOrMoreRooms = input.data?.booking.rooms.some(
|
||||
(room) => room.childrenInRoom?.length
|
||||
)
|
||||
const childrenInroom = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
totalChildren: input.data?.booking.rooms.reduce(
|
||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||
0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||
const totalRooms = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||
},
|
||||
{ totalRooms: input.roomCount }
|
||||
)
|
||||
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||
const isAllRoomsSelected = selectedRates.state === "ALL_SELECTED"
|
||||
|
||||
const showDiscounted =
|
||||
isUserLoggedIn || selectedRates.rates.some(isBookingCodeRate)
|
||||
|
||||
const mainRoomRate = selectedRates.rates.at(0)
|
||||
let mainRoomCurrency = getRoomCurrency(mainRoomRate)
|
||||
|
||||
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
const isTotalRegularPriceGreaterThanPrice =
|
||||
totalRegularPrice > selectedRates.totalPrice.local.price
|
||||
const showStrikedThroughPrice =
|
||||
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.summaryText}>
|
||||
{selectedRates.rates.map((room, index) => {
|
||||
return (
|
||||
<RateSummary
|
||||
key={index}
|
||||
room={room}
|
||||
roomIndex={index}
|
||||
isMultiRoom={selectedRates.rates.length > 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: selectedRates.rates.reduce((total, rate) => {
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const memberExists = "member" in rate && rate.member
|
||||
const publicExists = "public" in rate && rate.public
|
||||
if (!memberExists && !publicExists) {
|
||||
return total
|
||||
}
|
||||
const price =
|
||||
rate.member?.localPrice.pricePerStay ||
|
||||
rate.public?.localPrice.pricePerStay
|
||||
if (!price) {
|
||||
return total
|
||||
}
|
||||
const selectedPackagesPrice =
|
||||
rate.roomInfo.selectedPackages.reduce(
|
||||
(acc, pkg) => acc + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
return total + price + selectedPackagesPrice
|
||||
}, 0),
|
||||
currency: mainRoomCurrency ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
{showStrikedThroughPrice &&
|
||||
selectedRates.totalPrice.local.regularPrice && (
|
||||
<Caption
|
||||
textAlign="right"
|
||||
color="uiTextMediumContrast"
|
||||
striked={true}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{selectedRates.totalPrice.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.requested.price,
|
||||
selectedRates.totalPrice.requested.currency,
|
||||
selectedRates.totalPrice.requested.additionalPrice,
|
||||
selectedRates.totalPrice.requested.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.summaryPriceTextMobile}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</Caption>
|
||||
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
color="uiTextMediumContrast"
|
||||
className={styles.summaryPriceTextMobile}
|
||||
>
|
||||
{summaryPriceText}
|
||||
</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.continueButton}
|
||||
disabled={!isAllRoomsSelected || isSubmitting}
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RateSummary({
|
||||
roomIndex,
|
||||
room,
|
||||
isMultiRoom,
|
||||
}: {
|
||||
room: SelectedRate | undefined
|
||||
roomIndex: number
|
||||
isMultiRoom: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const getRateDetails = useRateDetails()
|
||||
|
||||
if (!room || !room.isSelected) {
|
||||
return (
|
||||
<div key={`unselected-${roomIndex}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomIndex + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Select room",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={roomIndex}>
|
||||
{isMultiRoom ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomIndex + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomInfo.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomInfo.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{getRateDetails(room.rate)}</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useRateDetails() {
|
||||
const intl = useIntl()
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
return (rate: RateEnum) => {
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRoomCurrency(rate: SelectedRate | undefined) {
|
||||
if (!rate) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ("member" in rate && rate.member?.localPrice) {
|
||||
return rate.member.localPrice.currency
|
||||
}
|
||||
|
||||
if ("public" in rate && rate.public?.localPrice) {
|
||||
return rate.public.localPrice.currency
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import useLang from "../../../../../../hooks/useLang"
|
||||
import PriceDetailsModal from "../../../../../PriceDetailsModal"
|
||||
import SignupPromoDesktop from "../../../../../SignupPromo/Desktop"
|
||||
import { useRateTitles } from "../../../Rooms/RoomsList/RoomListItem/Rates/useRateTitles"
|
||||
import { isBookingCodeRate } from "../../utils"
|
||||
import Room from "../Room"
|
||||
|
||||
import styles from "./summaryContent.module.css"
|
||||
|
||||
import type { Price } from "../../../../../../contexts/SelectRate/getTotalPrice"
|
||||
|
||||
export type SelectRateSummaryProps = {
|
||||
isMember: boolean
|
||||
bookingCode?: string
|
||||
toggleSummaryOpen: () => void
|
||||
}
|
||||
|
||||
export default function SummaryContent({
|
||||
isMember,
|
||||
toggleSummaryOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const { selectedRates, input } = useSelectRateContext()
|
||||
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const rateTitles = useRateTitles()
|
||||
|
||||
const nightsLabel = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: input.nights }
|
||||
)
|
||||
|
||||
const memberPrice =
|
||||
selectedRates.rates.length === 1 &&
|
||||
selectedRates.rates[0] &&
|
||||
"member" in selectedRates.rates[0]
|
||||
? selectedRates.rates[0].member
|
||||
: null
|
||||
|
||||
const containsBookingCodeRate = selectedRates.rates.find(
|
||||
(r) => r && isBookingCodeRate(r)
|
||||
)
|
||||
|
||||
if (!selectedRates?.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
|
||||
const showStrikeThroughPrice =
|
||||
totalRegularPrice > selectedRates?.totalPrice?.local?.price
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header>
|
||||
<div className={styles.headingWrapper}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3 className={styles.heading}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Booking summary",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
className={styles.closeButton}
|
||||
onPress={toggleSummaryOpen}
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p className={styles.dates}>
|
||||
{dt(input.data?.booking.fromDate)
|
||||
.locale(lang)
|
||||
.format(longDateFormat[lang])}
|
||||
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
|
||||
{dt(input.data?.booking.toDate)
|
||||
.locale(lang)
|
||||
.format(longDateFormat[lang])}{" "}
|
||||
({nightsLabel})
|
||||
</p>
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
|
||||
{selectedRates.rates.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Room
|
||||
key={idx}
|
||||
room={mapToRoom({
|
||||
isMember,
|
||||
rate: room,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
})}
|
||||
roomNumber={idx + 1}
|
||||
roomCount={selectedRates.rates.length}
|
||||
isMember={isMember}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{selectedRates.totalPrice.requested ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.approxPrice}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.requested.price,
|
||||
selectedRates.totalPrice.requested.currency,
|
||||
selectedRates.totalPrice.requested.additionalPrice,
|
||||
selectedRates.totalPrice.requested
|
||||
.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
data-testid="total-price"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted &&
|
||||
showStrikeThroughPrice &&
|
||||
selectedRates.totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PriceDetailsModal
|
||||
bookingCode={input.bookingCode}
|
||||
defaultCurrency={
|
||||
selectedRates.totalPrice.requested?.currency ??
|
||||
selectedRates.totalPrice.local.currency
|
||||
}
|
||||
rooms={selectedRates.rates
|
||||
.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapped = mapToRoom({
|
||||
isMember,
|
||||
rate: room,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
})
|
||||
|
||||
function getPrice(
|
||||
room: NonNullable<(typeof selectedRates.rates)[number]>,
|
||||
isMember: boolean
|
||||
) {
|
||||
switch (room.type) {
|
||||
case "regular":
|
||||
return {
|
||||
regular: isMember
|
||||
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||
: room.public?.localPrice,
|
||||
}
|
||||
case "campaign":
|
||||
return {
|
||||
campaign: isMember
|
||||
? (room.member ?? room.public)
|
||||
: room.public,
|
||||
}
|
||||
case "redemption":
|
||||
return {
|
||||
redemption: room.redemption,
|
||||
}
|
||||
case "code": {
|
||||
if ("corporateCheque" in room) {
|
||||
return {
|
||||
corporateCheque: room.corporateCheque,
|
||||
}
|
||||
}
|
||||
|
||||
if ("voucher" in room) {
|
||||
return {
|
||||
voucher: room.voucher,
|
||||
}
|
||||
}
|
||||
if ("public" in room) {
|
||||
return {
|
||||
regular: isMember
|
||||
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||
: room.public?.localPrice,
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown price type")
|
||||
}
|
||||
}
|
||||
|
||||
const p = getPrice(room!, isMember)
|
||||
|
||||
return {
|
||||
...mapped,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
price: p,
|
||||
bedType: undefined,
|
||||
breakfast: undefined,
|
||||
breakfastIncluded:
|
||||
room?.rateDefinition.breakfastIncluded ?? false,
|
||||
rateDefinition: room.rateDefinition,
|
||||
}
|
||||
})
|
||||
.filter((x) => !!x)}
|
||||
fromDate={input.data?.booking.fromDate ?? ""}
|
||||
toDate={input.data?.booking.toDate ?? ""}
|
||||
totalPrice={selectedRates.totalPrice}
|
||||
vat={selectedRates.vat}
|
||||
/>
|
||||
</div>
|
||||
{!isMember && memberPrice ? (
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: memberPrice.localPrice.pricePerStay,
|
||||
currency: memberPrice.localPrice.currency,
|
||||
}}
|
||||
badgeContent={"✌️"}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function mapToRoom({
|
||||
isMember,
|
||||
rate,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom,
|
||||
rateTitles,
|
||||
}: {
|
||||
isMember: boolean
|
||||
rate: NonNullable<
|
||||
ReturnType<typeof useSelectRateContext>["selectedRates"]["rates"][number]
|
||||
>
|
||||
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||
idx: number
|
||||
getPriceForRoom: (roomIndex: number) => Price | null
|
||||
rateTitles: ReturnType<typeof useRateTitles>
|
||||
}) {
|
||||
return {
|
||||
adults: input.data?.booking.rooms[idx].adults || 0,
|
||||
childrenInRoom: input.data?.booking.rooms[idx].childrenInRoom,
|
||||
roomType: rate.roomInfo.roomType,
|
||||
roomRate: rate,
|
||||
cancellationText: rateTitles[rate.rate].title,
|
||||
roomPrice: {
|
||||
perNight: { local: { price: -1, currency: CurrencyEnum.SEK } },
|
||||
perStay: getPriceForRoom(idx) ?? {
|
||||
local: { price: -1, currency: CurrencyEnum.Unknown },
|
||||
},
|
||||
},
|
||||
rateDetails: isMember
|
||||
? (rate.rateDefinitionMember?.generalTerms ??
|
||||
rate.rateDefinition.generalTerms)
|
||||
: rate.rateDefinition.generalTerms,
|
||||
packages: rate.roomInfo.selectedPackages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x3);
|
||||
}
|
||||
|
||||
.headingWrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
margin-top: -10px; /* Compensate for padding of the button */
|
||||
margin-right: -10px; /* Compensate for padding of the button */
|
||||
}
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: flex-start;
|
||||
color: var(--Text-Accent-Secondary);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--Space-x15);
|
||||
}
|
||||
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.approxPrice {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import 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 { Product } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { Price } from "../../../../../../types/price"
|
||||
|
||||
interface RoomProps {
|
||||
room: {
|
||||
adults: number
|
||||
childrenInRoom: Child[] | undefined
|
||||
roomType: string
|
||||
roomPrice: {
|
||||
perNight: Price
|
||||
perStay: Price
|
||||
}
|
||||
roomRate: Product
|
||||
rateDetails: string[] | undefined
|
||||
cancellationText: string
|
||||
packages?: Packages
|
||||
}
|
||||
roomNumber: number
|
||||
roomCount: number
|
||||
isMember: boolean
|
||||
}
|
||||
|
||||
export default function Room({
|
||||
room,
|
||||
roomNumber,
|
||||
roomCount,
|
||||
isMember,
|
||||
}: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
const roomPackages = room.packages
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
|
||||
<div>
|
||||
{roomCount > 1 ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.roomTitle}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{room.roomType}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.additionalInformation}>
|
||||
<p>{guestsParts.join(", ")}</p>
|
||||
<p>{room.cancellationText}</p>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.prices}>
|
||||
<p
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{showMemberPrice
|
||||
? formatPrice(
|
||||
intl,
|
||||
memberPrice.amount,
|
||||
memberPrice.currency
|
||||
)
|
||||
: formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</p>
|
||||
{showDiscounted && room.roomPrice.perStay.local.price ? (
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</s>
|
||||
) : null}
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
{room.rateDetails?.length ? (
|
||||
<div className={styles.ctaWrapper}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
className={styles.termsButton}
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rate details",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDetails.map((info) => (
|
||||
<Typography key={info} variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.termsText}>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</p>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{childBedCrib ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</p>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Subject to availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Subject to availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
{roomPackages?.map((pkg) => (
|
||||
<Typography key={pkg.code} variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>{pkg.description}</p>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
pkg.localPrice.price,
|
||||
pkg.localPrice.currency
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useIsLoggedIn } from "../../../../../hooks/useIsLoggedIn"
|
||||
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 = useIsLoggedIn()
|
||||
|
||||
const { selectedRates } = useSelectRateContext()
|
||||
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
scrollY.current = window.scrollY
|
||||
document.body.style.position = "fixed"
|
||||
document.body.style.top = `-${scrollY.current}px`
|
||||
document.body.style.width = "100%"
|
||||
} else {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
document.body.style.width = ""
|
||||
window.scrollTo({
|
||||
top: scrollY.current,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
document.body.style.width = ""
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
const containsBookingCodeRate = selectedRates.rates.find(
|
||||
(r) => r && isBookingCodeRate(r)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
|
||||
if (!selectedRates.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
|
||||
const showStrikeThroughPrice =
|
||||
totalRegularPrice > selectedRates.totalPrice.local?.price
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<SummaryContent
|
||||
isMember={isUserLoggedIn}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<ButtonRAC
|
||||
data-open={isSummaryOpen}
|
||||
onPress={toggleSummaryOpen}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span className={styles.priceLabel}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted &&
|
||||
showStrikeThroughPrice &&
|
||||
selectedRates.totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.seeDetails}>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See details",
|
||||
})}
|
||||
</span>
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
</Typography>
|
||||
</ButtonRAC>
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Primary"
|
||||
size="Large"
|
||||
type="submit"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
isDisabled={selectedRates.state !== "ALL_SELECTED"}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type {
|
||||
Rate,
|
||||
Room,
|
||||
} from "../../../../../types/components/selectRate/selectRate"
|
||||
import type { Price } from "../../../../../types/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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr auto;
|
||||
transition: all 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
z-index: var(--default-modal-z-index);
|
||||
|
||||
&[data-open="true"] {
|
||||
grid-template-rows: 1fr auto;
|
||||
|
||||
.bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-open="false"] .priceDetailsButton {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.signupPromoWrapper {
|
||||
position: relative;
|
||||
z-index: var(--default-modal-z-index);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--Overlay-40);
|
||||
z-index: var(--default-modal-overlay-z-index);
|
||||
}
|
||||
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
|
||||
align-items: flex-start;
|
||||
transition: all 0.5s ease-in-out;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
border-width: 0;
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 50dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.summaryAccordion {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.seeDetails {
|
||||
margin-top: var(--Space-x15);
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
align-items: center;
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Space-x2) 0 var(--Space-x7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
export function getMemberPrice(roomRate: Product) {
|
||||
if ("member" in roomRate && roomRate.member) {
|
||||
return {
|
||||
amount: roomRate.member.localPrice.pricePerStay,
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useTransition } from "react"
|
||||
|
||||
import { useSelectRateContext } from "../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { ErrorBoundary } from "../../../ErrorBoundary/ErrorBoundary"
|
||||
import { DesktopSummary } from "./DesktopSummary"
|
||||
import { MobileSummary } from "./MobileSummary"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
export function RateSummary() {
|
||||
return (
|
||||
<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}>
|
||||
<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}>
|
||||
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
|
||||
<MobileSummary />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
align-items: center;
|
||||
animation: slideUp 300ms ease forwards;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
bottom: -100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x4);
|
||||
padding-top: var(--Spacing-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promoContainer {
|
||||
display: none;
|
||||
max-width: 264px;
|
||||
}
|
||||
|
||||
.summaryPrice {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.petInfo {
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-left: var(--Spacing-x2);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPriceTextDesktop {
|
||||
align-self: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.continueButton {
|
||||
margin-left: auto;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.summaryPriceTextMobile {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.summary {
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.petInfo,
|
||||
.promoContainer,
|
||||
.summaryPriceTextDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.summaryPriceTextMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summaryPrice,
|
||||
.continueButton {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.summaryPriceContainer {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
|
||||
|
||||
import { sumPackages } from "../../../../utils/SelectRate"
|
||||
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type {
|
||||
Product,
|
||||
RedemptionProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { Rate } from "../../../../types/components/selectRate/selectRate"
|
||||
import type { Price } from "../../../../types/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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
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 { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useIsLoggedIn } from "../../../../../../hooks/useIsLoggedIn"
|
||||
|
||||
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 = useIsLoggedIn()
|
||||
const {
|
||||
selectedRates,
|
||||
input: { nights },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||
|
||||
const night = intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})
|
||||
|
||||
const isMainRoom = roomIndex === 0
|
||||
|
||||
if (!selectedRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
|
||||
(pkg) => pkg.localPrice.currency
|
||||
)
|
||||
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
|
||||
(total, pkg) => total + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
const selectedPackagesPricePerNight = Math.ceil(
|
||||
selectedPackagesPrice / nights
|
||||
)
|
||||
|
||||
if (
|
||||
isUserLoggedIn &&
|
||||
isMainRoom &&
|
||||
"member" in selectedRate &&
|
||||
selectedRate.member
|
||||
) {
|
||||
const { localPrice } = selectedRate.member
|
||||
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
}
|
||||
|
||||
if ("public" in selectedRate && selectedRate.public) {
|
||||
const { localPrice } = selectedRate.public
|
||||
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
}
|
||||
|
||||
if ("corporateCheque" in selectedRate) {
|
||||
const { localPrice } = selectedRate.corporateCheque
|
||||
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
|
||||
if (
|
||||
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
|
||||
localPrice.currency
|
||||
) {
|
||||
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
|
||||
return `${mainProductTitle} + ${packagesText}`
|
||||
}
|
||||
}
|
||||
|
||||
if ("voucher" in selectedRate) {
|
||||
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
|
||||
if (selectedPackagesPrice && selectedPackagesCurrency) {
|
||||
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
|
||||
return `${mainProductText} + ${packagesText}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useRateTitle(rate: RateEnum | undefined) {
|
||||
const intl = useIntl()
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.selectedRoomPanel {
|
||||
display: grid;
|
||||
grid-template-areas: "content image";
|
||||
grid-template-columns: 1fr 190px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
.img {
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
height: auto;
|
||||
max-height: 105px;
|
||||
object-fit: fill;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modifyButtonContainer {
|
||||
bottom: var(--Spacing-x1);
|
||||
position: absolute;
|
||||
right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
div.selectedRoomPanel p.subtitle {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.selectedRoomPanel {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-areas: "image" "content";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.img {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.img {
|
||||
max-height: 190px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { SelectedRoomPanel } from "./SelectedRoomPanel"
|
||||
import { roomSelectionPanelVariants } from "./variants"
|
||||
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
isMultiRoom: boolean
|
||||
roomIndex: number
|
||||
}
|
||||
export function MultiRoomWrapper({ children, isMultiRoom, roomIndex }: Props) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { getTopOffset } = useStickyPosition()
|
||||
const {
|
||||
activeRoomIndex,
|
||||
selectedRates,
|
||||
actions: { setActiveRoom },
|
||||
input: { data },
|
||||
} = useSelectRateContext()
|
||||
const roomNr = roomIndex + 1
|
||||
const adultCount = data?.booking.rooms[roomIndex]?.adults || 0
|
||||
const childCount = data?.booking.rooms[roomIndex]?.childrenInRoom?.length || 0
|
||||
const isActiveRoom = activeRoomIndex === roomIndex
|
||||
|
||||
const roomMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomNr }
|
||||
)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ adults: adultCount }
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: childCount,
|
||||
}
|
||||
)
|
||||
|
||||
const onlyAdultsMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
const guestsMsg = childCount ? adultsAndChildrenMsg : onlyAdultsMsg
|
||||
|
||||
const title = [roomMsg, guestsMsg].join(", ")
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 12 + getTopOffset()
|
||||
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
|
||||
|
||||
// If no room is active we will show all rooms collapsed, hence we want
|
||||
// to scroll to the first room.
|
||||
const selectedRoom =
|
||||
activeRoomIndex === -1 ? roomElements[0] : roomElements[activeRoomIndex]
|
||||
|
||||
if (selectedRoom) {
|
||||
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
|
||||
|
||||
// Setting a tiny delay for the scrolling. Without it the browser sometimes doesn't scroll up
|
||||
// after modifying the first room.
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 5)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeRoomIndex])
|
||||
|
||||
const selectedRate = selectedRates.rateSelectedForRoom(roomIndex)
|
||||
|
||||
if (isMultiRoom) {
|
||||
const classNames = roomSelectionPanelVariants({
|
||||
active: isActiveRoom,
|
||||
selected: !!selectedRate && !isActiveRoom,
|
||||
})
|
||||
return (
|
||||
<div className={styles.roomContainer} data-multiroom="true">
|
||||
<div className={styles.header}>
|
||||
{selectedRate && !isActiveRoom ? null : (
|
||||
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
|
||||
)}
|
||||
{selectedRate && isActiveRoom ? (
|
||||
<Button
|
||||
intent="text"
|
||||
onClick={() => {
|
||||
setActiveRoom("deselect")
|
||||
}}
|
||||
size="medium"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_up"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classNames}>
|
||||
<div className={styles.roomPanel}>
|
||||
<SelectedRoomPanel roomIndex={roomIndex} />
|
||||
</div>
|
||||
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.roomContainer {
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.roomPanel,
|
||||
.roomSelectionPanel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
grid-template-rows 0.3s ease;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.roomPanel > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roomSelectionPanel {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomSelectionPanel,
|
||||
.roomSelectionPanelContainer.selected .roomPanel {
|
||||
grid-template-rows: 1fr;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.active .roomPanel {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.roomSelectionPanelContainer.selected .roomSelectionPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.roomContainer {
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export const roomSelectionPanelVariants = cva(
|
||||
styles.roomSelectionPanelContainer,
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: styles.active,
|
||||
},
|
||||
selected: {
|
||||
true: styles.selected,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
active: false,
|
||||
selected: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
.hotelAlert {
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { 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 { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import useLang from "../../../../../hooks/useLang"
|
||||
|
||||
import styles from "./alert.module.css"
|
||||
|
||||
export default function NoAvailabilityAlert({
|
||||
roomIndex,
|
||||
}: {
|
||||
roomIndex: number
|
||||
}) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
const { availability, input } = useSelectRateContext()
|
||||
if (availability.isFetching || !availability.data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const indexed = availability.data[roomIndex]
|
||||
const hasAvailabilityError = "error" in indexed
|
||||
if (hasAvailabilityError) {
|
||||
return null
|
||||
}
|
||||
|
||||
const noAvailableRooms = hasAvailableRoomsForRoom(indexed.roomConfigurations)
|
||||
|
||||
const alertLink =
|
||||
roomIndex !== -1 &&
|
||||
(input.data?.booking.rooms.at(roomIndex)?.packages ?? []).length === 0
|
||||
? {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "See alternative hotels",
|
||||
}),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}
|
||||
: null
|
||||
|
||||
if (noAvailableRooms) {
|
||||
const text = intl.formatMessage({
|
||||
defaultMessage: "There are no rooms available that match your request.",
|
||||
})
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={text}
|
||||
link={alertLink}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublicPromotionWithCode = indexed.roomConfigurations.some((room) => {
|
||||
const filteredCampaigns = room.campaign.filter(Boolean)
|
||||
return filteredCampaigns.length
|
||||
? filteredCampaigns.every(
|
||||
(product) => !!product.rateDefinition?.isCampaignRate
|
||||
)
|
||||
: false
|
||||
})
|
||||
|
||||
const noAvailableBookingCodeRooms =
|
||||
!isPublicPromotionWithCode &&
|
||||
indexed.roomConfigurations.every(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.NotAvailable || !room.code.length
|
||||
)
|
||||
|
||||
if (input.bookingCode && noAvailableBookingCodeRooms) {
|
||||
const bookingCodeText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||
},
|
||||
{ bookingCode: input.bookingCode }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={bookingCodeText}
|
||||
link={alertLink}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function hasAvailableRoomsForRoom(
|
||||
roomConfigurations: Extract<
|
||||
NonNullable<
|
||||
ReturnType<typeof useSelectRateContext>["availability"]["data"]
|
||||
>[number],
|
||||
{ roomConfigurations: unknown }
|
||||
>["roomConfigurations"]
|
||||
) {
|
||||
return roomConfigurations.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
.bookingCodeFilter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.radio {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.radio[data-hovered] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio[data-focus-visible]::before {
|
||||
outline: 1px auto var(--Border-Interactive-Focus);
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio::before {
|
||||
flex-shrink: 0;
|
||||
content: "";
|
||||
margin-right: var(--Space-x15);
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
|
||||
}
|
||||
|
||||
.radio[data-selected]::before {
|
||||
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--Overlay-40);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--Space-x2) var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.radioGroup {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { 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 { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "../../../../../../hooks/useBreakpoint"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../stores/bookingCode-filter"
|
||||
|
||||
import styles from "./bookingCodeFilter.module.css"
|
||||
|
||||
export function BookingCodeFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
input,
|
||||
getAvailabilityForRoom,
|
||||
bookingCodeFilter,
|
||||
actions: { selectBookingCodeFilter },
|
||||
} = useSelectRateContext()
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex)
|
||||
|
||||
const bookingCodeFilterItems = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Booking code rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.Discounted,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "All rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.All,
|
||||
},
|
||||
]
|
||||
|
||||
async function updateFilterValue(selectedFilter: string) {
|
||||
selectBookingCodeFilter(selectedFilter as BookingCodeFilterEnum)
|
||||
}
|
||||
|
||||
const hideFilter = (roomAvailability ?? []).some((room) => {
|
||||
room.products.some((product) => {
|
||||
const isRedemption = Array.isArray(product)
|
||||
if (isRedemption) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch (product.rateDefinition.rateType) {
|
||||
case RateTypeEnum.Arb:
|
||||
case RateTypeEnum.CorporateCheque:
|
||||
case RateTypeEnum.Voucher:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (hideFilter || !input?.bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{
|
||||
bookingCodeFilterItems.find(
|
||||
(item) => item.value === bookingCodeFilter
|
||||
)?.label
|
||||
}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
{!displayAsModal ? (
|
||||
<Popover placement="bottom end" isNonModal>
|
||||
<Dialog className={styles.dialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
autoFocus={bookingCodeFilter === item.value}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
) : (
|
||||
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.modalDialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room rates",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={() => {
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="close"
|
||||
size={24}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BookingCodeChip } from "@scandic-hotels/design-system/BookingCodeChip"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
|
||||
export function RemoveBookingCodeButton() {
|
||||
const {
|
||||
input: { bookingCode },
|
||||
actions: { removeBookingCode },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (!bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingCodeChip
|
||||
bookingCode={bookingCode}
|
||||
filledIcon
|
||||
withCloseButton={true}
|
||||
withText={false}
|
||||
onClose={() => {
|
||||
removeBookingCode()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./petRoom.module.css"
|
||||
|
||||
export default function PetRoomMessage({
|
||||
priceData,
|
||||
}: {
|
||||
priceData?: { price: number; currency: string }
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!priceData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.additionalInformation}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.additionalInformationPrice}>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
price: formatPrice(intl, priceData.price, priceData.currency),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.additionalInformation {
|
||||
color: var(--Text-Tertiary);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
}
|
||||
|
||||
.additionalInformationPrice {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.checkboxGroup {
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
display: grid;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.checkboxField {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--Space-x15);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
cursor: pointer;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
transition: background-color 0.3s;
|
||||
color: var(--Text-Default);
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: unset;
|
||||
|
||||
.checkbox {
|
||||
border-color: var(--Border-Interactive-Disabled);
|
||||
background-color: var(--Surface-UI-Fill-Disabled);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--UI-Input-Controls-Surface-Hover);
|
||||
}
|
||||
|
||||
&[data-focus-visible] .checkbox {
|
||||
/* Used this value as it makes sense from a token name perspective and has a good contrast, but we need to decide for a default ui state */
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-selected] .checkbox {
|
||||
border-color: var(--Surface-UI-Fill-Active);
|
||||
background-color: var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.checkboxField:hover:not([data-disabled]) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.checkboxField[data-selected] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
import { Checkbox, CheckboxGroup } from "react-aria-components"
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { usePackageLabels } from "../../usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "../../utils"
|
||||
|
||||
import styles from "./checkbox.module.css"
|
||||
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "../formValues"
|
||||
|
||||
export function PackageCheckboxes({
|
||||
availablePackages,
|
||||
}: {
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message?: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const { control } = useFormContext<FormValues>()
|
||||
const packageLabels = usePackageLabels()
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedPackages"
|
||||
render={({ field }) => {
|
||||
const allergyRoomSelected = includesAllergyRoom(field.value)
|
||||
const petRoomSelected = includesPetRoom(field.value)
|
||||
return (
|
||||
<CheckboxGroup {...field} className={styles.checkboxGroup}>
|
||||
{availablePackages?.map((option) => {
|
||||
const isAllergyRoom = checkIsAllergyRoom(option.code)
|
||||
const isPetRoom = checkIsPetRoom(option.code)
|
||||
const isDisabled =
|
||||
(isPetRoom && allergyRoomSelected) ||
|
||||
(isAllergyRoom && petRoomSelected)
|
||||
|
||||
const isSelected = field.value.includes(option.code)
|
||||
const iconName = getIconNameByPackageCode(option.code)
|
||||
|
||||
return (
|
||||
<div key={option.code} className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
key={option.code}
|
||||
className={styles.checkboxField}
|
||||
isDisabled={isDisabled}
|
||||
value={option.code}
|
||||
>
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected ? (
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" />
|
||||
) : null}
|
||||
</span>
|
||||
<Typography
|
||||
className={styles.text}
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
>
|
||||
<span>{packageLabels[option.code]}</span>
|
||||
</Typography>
|
||||
{iconName ? (
|
||||
<MaterialIcon icon={iconName} color="Icon/Default" />
|
||||
) : null}
|
||||
</Checkbox>
|
||||
{option.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CheckboxGroup>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function includesAllergyRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||
}
|
||||
|
||||
export function includesPetRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
|
||||
}
|
||||
|
||||
export function checkIsAllergyRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.ALLERGY_ROOM {
|
||||
return code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
}
|
||||
|
||||
export function checkIsPetRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.PET_ROOM {
|
||||
return code === RoomPackageCodeEnum.PET_ROOM
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.footer {
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Space-x15) 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
.footer {
|
||||
margin-top: var(--Space-x5);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.buttonContainer {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export type FormValues = {
|
||||
selectedPackages: PackageEnum[]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PackageCheckboxes } from "./Checkboxes"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "./formValues"
|
||||
|
||||
export function RoomPackagesForm({
|
||||
close,
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
close: () => void
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
selectedPackages: PackageEnum[]
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
values: {
|
||||
selectedPackages: selectedPackages,
|
||||
},
|
||||
})
|
||||
|
||||
function clearSelectedPackages() {
|
||||
onSelectPackages([])
|
||||
close()
|
||||
}
|
||||
|
||||
function onSubmit(data: FormValues) {
|
||||
onSelectPackages(data.selectedPackages)
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<PackageCheckboxes availablePackages={availablePackages} />
|
||||
<div className={styles.footer}>
|
||||
<Divider color="Border/Divider/Subtle" className={styles.divider} />
|
||||
<div className={styles.buttonContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button variant="Tertiary" size="Small" type="submit">
|
||||
{intl.formatMessage({ defaultMessage: "Apply" })}
|
||||
</Button>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button
|
||||
onPress={clearSelectedPackages}
|
||||
size="Small"
|
||||
variant="Text"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Clear",
|
||||
})}
|
||||
</Button>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterModal({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
|
||||
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.modalDialog}>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<MaterialIcon icon="close" size={24} color="CurrentColor" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterPopover({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{intl.formatMessage({ defaultMessage: "Special needs" })}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
|
||||
<Popover placement="bottom end" className={styles.popover}>
|
||||
<Dialog>
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "../../../../../../hooks/useBreakpoint"
|
||||
import PetRoomMessage from "./Form/Checkboxes/PetRoomMessage"
|
||||
import { RoomPackageFilterModal } from "./Modal"
|
||||
import { RoomPackageFilterPopover } from "./Popover"
|
||||
import { usePackageLabels } from "./usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "./utils"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export function RoomPackageFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
getPackagesForRoom,
|
||||
actions: { selectPackages },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const { selectedPackages, availablePackages } = getPackagesForRoom(roomIndex)
|
||||
|
||||
function deletePackage(code: PackageEnum) {
|
||||
selectPackages({
|
||||
roomIndex,
|
||||
packages: selectedPackages
|
||||
.filter((pkg) => pkg.code !== code)
|
||||
.map((pkg) => pkg.code),
|
||||
})
|
||||
}
|
||||
|
||||
const petRoomPackage = availablePackages.find(
|
||||
(x) => x.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const packageLabels = usePackageLabels()
|
||||
const packageMessages = packageMessageMap({
|
||||
petRoomPrice:
|
||||
petRoomPackage && !("type" in petRoomPackage)
|
||||
? petRoomPackage.localPrice
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const packages = availablePackages
|
||||
.map((x) => {
|
||||
if (!isRoomPackage(x)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
code: x.code,
|
||||
message: packageMessages[x.code],
|
||||
}
|
||||
})
|
||||
.filter((x) => {
|
||||
return !!x
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.roomPackageFilter}>
|
||||
<div className={styles.selectedPackages}>
|
||||
{selectedPackages.map((pkg) => (
|
||||
<Typography
|
||||
key={pkg.code}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span className={styles.selectedPackage}>
|
||||
<MaterialIcon
|
||||
icon={getIconNameByPackageCode(pkg.code)}
|
||||
size={16}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
{packageLabels[pkg.code] ?? pkg.description}
|
||||
<ButtonRAC
|
||||
onPress={() => deletePackage(pkg.code)}
|
||||
className={styles.removeButton}
|
||||
>
|
||||
<MaterialIcon icon="close" size={16} color="CurrentColor" />
|
||||
</ButtonRAC>
|
||||
</span>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
{displayAsModal ? (
|
||||
<div>
|
||||
<RoomPackageFilterModal
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<RoomPackageFilterPopover
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isRoomPackage(x: {
|
||||
code: BreakfastPackageEnum | RoomPackageCodeEnum
|
||||
}): x is { code: RoomPackageCodeEnum } {
|
||||
return Object.values(RoomPackageCodeEnum).includes(
|
||||
x.code as RoomPackageCodeEnum
|
||||
)
|
||||
}
|
||||
|
||||
const packageMessageMap = ({
|
||||
petRoomPrice,
|
||||
}: {
|
||||
petRoomPrice?: { price: number; currency: string }
|
||||
}): Record<RoomPackageCodeEnum, ReactNode | undefined> => ({
|
||||
[RoomPackageCodeEnum.PET_ROOM]: <PetRoomMessage priceData={petRoomPrice} />,
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: undefined,
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: undefined,
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
.roomPackageFilter {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.selectedPackages {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--Overlay-40);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--Space-x2) var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
padding: 0 var(--Space-x15);
|
||||
}
|
||||
|
||||
.selectedPackage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Space-x1);
|
||||
gap: var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
background-color: var(--Surface-Secondary-Default-dark);
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
padding: var(--Space-x05);
|
||||
margin: calc(-1 * var(--Space-x05));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.roomPackageFilter {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: var(--Space-x2);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
export const usePackageLabels = () => {
|
||||
const intl = useIntl()
|
||||
|
||||
const labels: Record<RoomPackageCodeEnum, string> = {
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Allergy-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.PET_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Pet-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Accessible room",
|
||||
}),
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { MaterialSymbolProps } from "@scandic-hotels/design-system/Icons/MaterialIcon/MaterialSymbol"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function getIconNameByPackageCode(
|
||||
packageCode: PackageEnum
|
||||
): MaterialSymbolProps["icon"] {
|
||||
switch (packageCode) {
|
||||
case RoomPackageCodeEnum.PET_ROOM:
|
||||
return "pets"
|
||||
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
|
||||
return "accessible"
|
||||
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
||||
return "mode_fan"
|
||||
default:
|
||||
return "star"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { ErrorBoundary } from "../../../../ErrorBoundary/ErrorBoundary"
|
||||
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||
import { RoomPackageFilter } from "./RoomPackageFilter"
|
||||
|
||||
import styles from "./roomsHeader.module.css"
|
||||
|
||||
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
|
||||
<InnerRoomsHeader roomIndex={roomIndex} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerRoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<AvailableRoomCount roomIndex={roomIndex} />
|
||||
<div className={styles.filters}>
|
||||
<RemoveBookingCodeButton />
|
||||
<RoomPackageFilter roomIndex={roomIndex} />
|
||||
{/* <BookingCodeFilter roomIndex={roomIndex} /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvailableRoomCount({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const { isFetching, getAvailabilityForRoom } = useSelectRateContext()
|
||||
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex) ?? []
|
||||
|
||||
const availableRooms = roomAvailability.filter(
|
||||
(x) => x.status === AvailabilityEnum.Available
|
||||
).length
|
||||
|
||||
const totalRooms = roomAvailability.length
|
||||
|
||||
const notAllRoomsAvailableText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
availableRooms,
|
||||
numberOfRooms: totalRooms,
|
||||
}
|
||||
)
|
||||
|
||||
const allRoomsAvailableText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{numberOfRooms, plural, one {# room type} other {# room types}} available",
|
||||
},
|
||||
{
|
||||
numberOfRooms: totalRooms,
|
||||
}
|
||||
)
|
||||
|
||||
if (isFetching) {
|
||||
return <SkeletonShimmer height="30px" width="25ch" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
|
||||
<p>
|
||||
{availableRooms !== totalRooms
|
||||
? notAllRoomsAvailableText
|
||||
: allRoomsAvailableText}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.availableRooms {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
type RoomSizeProps = {
|
||||
roomSize:
|
||||
| {
|
||||
max: number
|
||||
min: number
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
<p>∙</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<h4>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{roomSizeMin} - {roomSizeMax} m²",
|
||||
},
|
||||
{
|
||||
roomSizeMin: roomSize.min,
|
||||
roomSizeMax: roomSize.max,
|
||||
}
|
||||
)}
|
||||
</h4>
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.specification {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--Space-x1);
|
||||
padding-bottom: var(--Space-x05);
|
||||
}
|
||||
|
||||
.sidePeekButton {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import RoomSize from "./RoomSize"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type { RoomInfo } from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
type Props = {
|
||||
roomInfo: RoomInfo
|
||||
}
|
||||
|
||||
export default function Details({ roomInfo }: Props) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { name, occupancy, roomSize } = roomInfo || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.specification}>
|
||||
{occupancy && (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<h4>
|
||||
{occupancy.max === occupancy.min
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{guests, plural, one {# guest} other {# guests}}",
|
||||
},
|
||||
{ guests: occupancy.max }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{min}-{max} guests",
|
||||
},
|
||||
{
|
||||
min: occupancy.min,
|
||||
max: occupancy.max,
|
||||
}
|
||||
)}
|
||||
</h4>
|
||||
</Typography>
|
||||
)}
|
||||
<RoomSize roomSize={roomSize} />
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h2>{name}</h2>
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.breakfastMessage {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: 1;
|
||||
min-width: 5%;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HotelTypeEnum } from "@scandic-hotels/trpc/enums/hotelType"
|
||||
|
||||
export function getBreakfastMessage(
|
||||
publicBreakfastIncluded: boolean,
|
||||
memberBreakfastIncluded: boolean,
|
||||
hotelType: string | undefined,
|
||||
userIsLoggedIn: boolean,
|
||||
msgs: Record<
|
||||
"included" | "noSelection" | "scandicgo" | "notIncluded",
|
||||
string
|
||||
>,
|
||||
roomNr: number
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
return msgs.notIncluded
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useIsLoggedIn } from "../../../../../../../../hooks/useIsLoggedIn"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../../../stores/bookingCode-filter"
|
||||
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 isUserLoggedIn = useIsLoggedIn()
|
||||
|
||||
const breakfastMessages = {
|
||||
included: intl.formatMessage({
|
||||
defaultMessage: "Breakfast is included.",
|
||||
}),
|
||||
notIncluded: intl.formatMessage({
|
||||
defaultMessage: "Breakfast excluded, add in next step.",
|
||||
}),
|
||||
noSelection: intl.formatMessage({
|
||||
defaultMessage: "Select a rate",
|
||||
}),
|
||||
scandicgo: intl.formatMessage({
|
||||
defaultMessage: "Breakfast deal can be purchased at the hotel.",
|
||||
}),
|
||||
}
|
||||
|
||||
const breakfastMessage = getBreakfastMessage(
|
||||
breakfastIncludedStandard,
|
||||
breakfastIncludedMember,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
breakfastMessages,
|
||||
roomNr
|
||||
)
|
||||
|
||||
const isDiscount = selectedFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
if (isDiscount || !hasRegularRates) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
<Divider className={styles.divider} color="Border/Divider/Subtle" />
|
||||
<Typography
|
||||
variant={"Body/Supporting text (caption)/smRegular"}
|
||||
className={styles.breakfastMessage}
|
||||
>
|
||||
<p>{breakfastMessage}</p>
|
||||
</Typography>
|
||||
<Divider className={styles.divider} color="Border/Divider/Subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import CampaignRateCard from "@scandic-hotels/design-system/CampaignRateCard"
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useIsLoggedIn } from "../../../../../../../hooks/useIsLoggedIn"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../../stores/bookingCode-filter"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "../../../../../../../utils/SelectRate"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
import { useRateTitles } from "./useRateTitles"
|
||||
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
type CampaignProps = {
|
||||
nights: number
|
||||
campaign: AvailabilityWithRoomInfo["campaign"]
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export default function Campaign({
|
||||
campaign,
|
||||
roomIndex,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
selectedPackages,
|
||||
}: CampaignProps) {
|
||||
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||
|
||||
const isCampaignRate = campaign.some(
|
||||
(c) =>
|
||||
c.rateDefinition.isCampaignRate || c.rateDefinitionMember?.isCampaignRate
|
||||
)
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted && !isCampaignRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||
campaign = campaign.filter((product) => product.bookingCode)
|
||||
}
|
||||
|
||||
return campaign.map((product, ix) => {
|
||||
return (
|
||||
<Inner
|
||||
key={ix}
|
||||
product={product}
|
||||
nights={nights}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function Inner({
|
||||
product,
|
||||
roomIndex,
|
||||
roomTypeCode,
|
||||
selectedPackages,
|
||||
nights,
|
||||
}: {
|
||||
roomIndex: number
|
||||
nights: number
|
||||
roomTypeCode: string
|
||||
product: AvailabilityWithRoomInfo["campaign"][number]
|
||||
selectedPackages: Package[]
|
||||
}) {
|
||||
const roomNr = roomIndex + 1
|
||||
const {
|
||||
isRateSelected,
|
||||
actions: { selectRate },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const rateTitles = useRateTitles()
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "../../../../../../../utils/SelectRate"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
import { useRateTitles } from "./useRateTitles"
|
||||
|
||||
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { Package } from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
type CodeProps = {
|
||||
nights: number
|
||||
roomTypeCode: string
|
||||
code: CodeProduct[]
|
||||
roomIndex: number
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export default function Code({
|
||||
code,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: CodeProps) {
|
||||
return code.map((product) => {
|
||||
return (
|
||||
<InnerCode
|
||||
key={product.rate}
|
||||
codeProduct={product}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
nights={nights}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function InnerCode({
|
||||
codeProduct,
|
||||
roomIndex,
|
||||
roomTypeCode,
|
||||
nights,
|
||||
selectedPackages,
|
||||
}: {
|
||||
codeProduct: CodeProduct
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
nights: number
|
||||
selectedPackages: Package[]
|
||||
}) {
|
||||
const {
|
||||
input: { bookingCode },
|
||||
actions: { selectRate },
|
||||
isRateSelected,
|
||||
} = useSelectRateContext()
|
||||
|
||||
function handleSelectRate(rateCode: string) {
|
||||
selectRate({ roomIndex, rateCode, roomTypeCode, 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[] }[]
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../../stores/bookingCode-filter"
|
||||
import { sumPackages } from "../../../../../../../utils/SelectRate"
|
||||
import { useRateTitles } from "./useRateTitles"
|
||||
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
type RedemptionsProps = {
|
||||
redemptions: AvailabilityWithRoomInfo["redemptions"]
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
roomIndex: number
|
||||
}
|
||||
|
||||
export default function Redemptions({
|
||||
redemptions,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: RedemptionsProps) {
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const {
|
||||
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",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
import RegularRateCard from "@scandic-hotels/design-system/RegularRateCard"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { useIsLoggedIn } from "../../../../../../../hooks/useIsLoggedIn"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../../stores/bookingCode-filter"
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "../../../../../../../utils/SelectRate"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
import { useRateTitles } from "./useRateTitles"
|
||||
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
interface Rate {
|
||||
label: string
|
||||
price: string
|
||||
unit: string
|
||||
}
|
||||
|
||||
interface Rates {
|
||||
memberRate?: Rate
|
||||
rate?: Rate
|
||||
}
|
||||
type RegularRateProps = {
|
||||
nights: number
|
||||
regular: AvailabilityWithRoomInfo["regular"]
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export function RegularRate({
|
||||
nights,
|
||||
regular,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: RegularRateProps) {
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { BookingCodeFilterEnum } from "../../../../../../../stores/bookingCode-filter"
|
||||
import { BreakfastMessage } from "./BreakfastMessage"
|
||||
import Campaign from "./Campaign"
|
||||
import Code from "./Code"
|
||||
import Redemptions from "./Redemptions"
|
||||
import { RegularRate } from "./Regular"
|
||||
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "../../../../../../../contexts/SelectRate/types"
|
||||
|
||||
export interface RatesProps {
|
||||
roomConfiguration: AvailabilityWithRoomInfo
|
||||
roomIndex: number
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
export function Rates({
|
||||
roomConfiguration: {
|
||||
breakfastIncludedInAllRates,
|
||||
breakfastIncludedInAllRatesMember,
|
||||
campaign,
|
||||
code,
|
||||
redemptions,
|
||||
regular,
|
||||
roomTypeCode,
|
||||
},
|
||||
selectedPackages,
|
||||
roomIndex,
|
||||
}: RatesProps) {
|
||||
const {
|
||||
bookingCodeFilter,
|
||||
input: { nights },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const sharedProps = {
|
||||
nights,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}
|
||||
const showAllRates = bookingCodeFilter === BookingCodeFilterEnum.All
|
||||
const hasBookingCodeRates = !!(campaign.length || code.length)
|
||||
const hasRegularRates = !!regular.length
|
||||
const showDivider = showAllRates && hasBookingCodeRates && hasRegularRates
|
||||
|
||||
return (
|
||||
<>
|
||||
<Code {...sharedProps} code={code} />
|
||||
<Campaign {...sharedProps} campaign={campaign} />
|
||||
<Redemptions {...sharedProps} redemptions={redemptions} />
|
||||
{showDivider ? <Divider color="Border/Divider/Subtle" /> : null}
|
||||
<BreakfastMessage
|
||||
breakfastIncludedMember={breakfastIncludedInAllRatesMember}
|
||||
breakfastIncludedStandard={breakfastIncludedInAllRates}
|
||||
hasRegularRates={hasRegularRates && showAllRates}
|
||||
roomIndex={roomIndex}
|
||||
/>
|
||||
<RegularRate {...sharedProps} regular={regular} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CorporateChequeProduct,
|
||||
PriceProduct,
|
||||
VoucherProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { SelectedRate } from "../../../../../../../types/stores/rates"
|
||||
|
||||
export function isSelectedPriceProduct(
|
||||
product: PriceProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string
|
||||
) {
|
||||
if (!selectedRate || roomTypeCode !== selectedRate.roomTypeCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { member, public: standard } = product
|
||||
let isSelected = false
|
||||
if (
|
||||
"member" in selectedRate.product &&
|
||||
selectedRate.product.member &&
|
||||
member
|
||||
) {
|
||||
isSelected = selectedRate.product.member.rateCode === member.rateCode
|
||||
}
|
||||
|
||||
if (
|
||||
"public" in selectedRate.product &&
|
||||
selectedRate.product.public &&
|
||||
standard
|
||||
) {
|
||||
isSelected = selectedRate.product.public.rateCode === standard.rateCode
|
||||
}
|
||||
|
||||
return isSelected
|
||||
}
|
||||
|
||||
export function isSelectedCorporateCheque(
|
||||
product: CorporateChequeProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string
|
||||
) {
|
||||
if (!selectedRate || !("corporateCheque" in selectedRate.product)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSameRateCode =
|
||||
product.corporateCheque.rateCode ===
|
||||
selectedRate.product.corporateCheque.rateCode
|
||||
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||
return isSameRateCode && isSameRoomTypeCode
|
||||
}
|
||||
|
||||
export function isSelectedVoucher(
|
||||
product: VoucherProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string
|
||||
) {
|
||||
if (!selectedRate || !("voucher" in selectedRate.product)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSameRateCode =
|
||||
product.voucher.rateCode === selectedRate.product.voucher.rateCode
|
||||
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||
return isSameRateCode && isSameRoomTypeCode
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function calculatePricePerNightPriceProduct(
|
||||
pricePerNight: number,
|
||||
requestedPricePerNight: number | undefined,
|
||||
nights: number,
|
||||
packagesSumLocal: number,
|
||||
packagesSumRequested: number
|
||||
) {
|
||||
const totalPrice = packagesSumLocal
|
||||
? Math.floor(pricePerNight + packagesSumLocal / nights)
|
||||
: Math.floor(pricePerNight)
|
||||
|
||||
let totalRequestedPrice = undefined
|
||||
if (requestedPricePerNight) {
|
||||
if (packagesSumRequested) {
|
||||
totalRequestedPrice = Math.floor(
|
||||
requestedPricePerNight + packagesSumRequested / nights
|
||||
)
|
||||
} else {
|
||||
totalRequestedPrice = Math.floor(requestedPricePerNight)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalPrice: totalPrice.toString(),
|
||||
totalRequestedPrice: totalRequestedPrice?.toString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
export function useRateTitles() {
|
||||
const intl = useIntl()
|
||||
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
return {
|
||||
change: {
|
||||
paymentTerm: payNow,
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
}),
|
||||
},
|
||||
flex: {
|
||||
paymentTerm: intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
}),
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
}),
|
||||
},
|
||||
save: {
|
||||
paymentTerm: payNow,
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
}),
|
||||
},
|
||||
noPriceAvailable: intl.formatMessage({
|
||||
defaultMessage: "No prices available",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
import { memo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { useSelectRateContext } from "../../../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { mapApiImagesToGalleryImages } from "../../../../../../../misc/imageGallery"
|
||||
import { IconForFeatureCode } from "../../../../../../../utils/SelectRate"
|
||||
import { RoomDetailsSidePeek } from "../../../../../../RoomDetailsSidePeek"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import styles from "./notAvailable.module.css"
|
||||
|
||||
export default function RoomNotAvailable() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.noRoomsContainer}>
|
||||
<div className={styles.noRooms}>
|
||||
<MaterialIcon
|
||||
icon="error_circle_rounded"
|
||||
color="Icon/Feedback/Error"
|
||||
size={16}
|
||||
/>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "This room is not available",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.noRooms {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin: 0;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import Details from "./Details"
|
||||
import { listItemVariants } from "./listItemVariants"
|
||||
import { Rates } from "./Rates"
|
||||
import RoomImage from "./RoomImage"
|
||||
import RoomNotAvailable from "./RoomNotAvailable"
|
||||
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "../../../../../../contexts/SelectRate/types"
|
||||
|
||||
export type RoomListItemProps = {
|
||||
room: AvailabilityWithRoomInfo
|
||||
selectedPackages: Package[]
|
||||
roomIndex: number
|
||||
hotelId: string
|
||||
}
|
||||
|
||||
export function RoomListItem({
|
||||
room,
|
||||
selectedPackages,
|
||||
roomIndex,
|
||||
hotelId,
|
||||
}: RoomListItemProps) {
|
||||
if (!room || !room.roomInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const classNames = listItemVariants({
|
||||
availability:
|
||||
room.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<RoomImage
|
||||
roomType={room.roomType}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
roomsLeft={room.roomsLeft}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
images={room.roomInfo.images ?? []}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
<Details roomInfo={room.roomInfo} />
|
||||
|
||||
<div className={styles.container}>
|
||||
{room.status === AvailabilityEnum.NotAvailable ? (
|
||||
<RoomNotAvailable />
|
||||
) : (
|
||||
<Rates
|
||||
roomConfiguration={room}
|
||||
roomIndex={roomIndex}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
export const listItemVariants = cva(styles.listItem, {
|
||||
variants: {
|
||||
availability: {
|
||||
noAvailability: styles.noAvailability,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
.listItem {
|
||||
align-content: flex-start;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .listItem {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem.noAvailability {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { RoomCardSkeleton } from "../../../../RoomCardSkeleton/RoomCardSkeleton"
|
||||
|
||||
import styles from "./roomsListSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function RoomsListSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function ScrollToList() {
|
||||
const {
|
||||
input: { isMultiRoom },
|
||||
selectedRates,
|
||||
} = useSelectRateContext()
|
||||
const selectedRateCode = selectedRates.rates[0]
|
||||
? `${selectedRates.rates[0].rateDefinition.rateCode}${selectedRates.rates[0].roomInfo.roomTypeCode}`
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (isMultiRoom) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedRateCode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Required to prevent the history.pushState on the first selection
|
||||
// to scroll user back to top
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 173 // summary on mobile is 163px
|
||||
|
||||
const selectedRateCard: HTMLElement | null = document.querySelector(
|
||||
`.${styles.roomList} label:has(input[type=radio]:checked)`
|
||||
)
|
||||
|
||||
if (selectedRateCard) {
|
||||
const elementPosition = selectedRateCard.getBoundingClientRect().top
|
||||
const windowHeight = window.innerHeight
|
||||
const offsetPosition =
|
||||
elementPosition +
|
||||
window.scrollY -
|
||||
(windowHeight - selectedRateCard.offsetHeight - SCROLL_OFFSET)
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [isMultiRoom, selectedRateCode])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
import { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { RoomListItem } from "./RoomListItem"
|
||||
import { RoomsListSkeleton } from "./RoomsListSkeleton"
|
||||
import ScrollToList from "./ScrollToList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function RoomsList({ roomIndex }: { roomIndex: number }) {
|
||||
const { getAvailabilityForRoom, isFetching, input, getPackagesForRoom } =
|
||||
useSelectRateContext()
|
||||
|
||||
if (isFetching) {
|
||||
return <RoomsListSkeleton />
|
||||
}
|
||||
|
||||
const hotelId = input?.data?.booking.hotelId
|
||||
if (!hotelId) {
|
||||
throw new Error("Hotel ID is required to display room availability")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToList />
|
||||
<ul className={styles.roomList}>
|
||||
{getAvailabilityForRoom(roomIndex)?.map((room, ix) => {
|
||||
return (
|
||||
<RoomListItem
|
||||
key={room.roomTypeCode + ix}
|
||||
room={room}
|
||||
selectedPackages={getPackagesForRoom(roomIndex).selectedPackages}
|
||||
roomIndex={roomIndex}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.roomList {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roomList > li {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.container {
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* used to hide overflowing rows */
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useSelectRateContext } from "../../../../contexts/SelectRate/SelectRateContext"
|
||||
import { MultiRoomWrapper } from "./MultiRoomWrapper"
|
||||
import NoAvailabilityAlert from "./NoAvailabilityAlert"
|
||||
import { RoomsHeader } from "./RoomsHeader"
|
||||
import RoomsList from "./RoomsList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function Rooms() {
|
||||
const {
|
||||
availability,
|
||||
input: { isMultiRoom },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (!availability) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{availability.data?.map((_room, idx) => {
|
||||
return (
|
||||
<MultiRoomWrapper
|
||||
key={`${idx}`}
|
||||
roomIndex={idx}
|
||||
isMultiRoom={isMultiRoom}
|
||||
>
|
||||
<RoomsHeader roomIndex={idx} />
|
||||
<NoAvailabilityAlert roomIndex={idx} />
|
||||
<RoomsList roomIndex={idx} />
|
||||
</MultiRoomWrapper>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.content {
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* used to hide overflowing rows */
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RoomCardSkeleton } from "../../RoomCardSkeleton/RoomCardSkeleton"
|
||||
|
||||
import styles from "./RoomsContainerSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function RoomsContainerSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}></div>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.errorContainer {
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
width: 100%;
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { TRPCClientError } from "@trpc/client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
|
||||
import { useSelectRateContext } from "../../../contexts/SelectRate/SelectRateContext"
|
||||
import { RateSummary } from "./RateSummary"
|
||||
import Rooms from "./Rooms"
|
||||
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
|
||||
|
||||
import styles from "./index.module.css"
|
||||
|
||||
import type { HotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface RoomsContainerProps
|
||||
extends Pick<HotelData, "roomCategories">,
|
||||
Pick<HotelData["hotel"], "hotelType" | "vat"> {}
|
||||
|
||||
export function RoomsContainer({}: RoomsContainerProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const {
|
||||
availability: { error, isFetching, isError },
|
||||
input: { hasError: hasInputError, errorCode },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (isFetching) {
|
||||
return <RoomsContainerSkeleton />
|
||||
}
|
||||
|
||||
if (isError || hasInputError) {
|
||||
const errorMessage = getErrorMessage(error ?? errorCode, intl)
|
||||
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<Alert type={AlertTypeEnum.Alarm} heading={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rooms />
|
||||
<RateSummary />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, intl: ReturnType<typeof useIntl>) {
|
||||
let errorCode = ""
|
||||
if (error instanceof TRPCClientError) {
|
||||
errorCode = error.data?.zodError?.formErrors?.at(0)
|
||||
} else if (typeof error == "string") {
|
||||
errorCode = error
|
||||
}
|
||||
|
||||
switch (errorCode) {
|
||||
case "FROMDATE_INVALID":
|
||||
case "TODATE_INVALID":
|
||||
case "TODATE_MUST_BE_AFTER_FROMDATE":
|
||||
case "FROMDATE_CANNOT_BE_IN_THE_PAST": {
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Invalid dates",
|
||||
})
|
||||
}
|
||||
default:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Something went wrong",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HotelInfoCardSkeleton } from "@scandic-hotels/design-system/HotelInfoCard"
|
||||
|
||||
import { RoomsContainerSkeleton } from "./RoomsContainer/RoomsContainerSkeleton"
|
||||
|
||||
export function SelectRateSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<HotelInfoCardSkeleton />
|
||||
<RoomsContainerSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
|
||||
import type { Room } from "../../../types/components/selectRate/selectRate"
|
||||
|
||||
type ChildrenInRoom = (Child[] | null)[] | null
|
||||
|
||||
type SelectRateTrackingInput = {
|
||||
lang: Lang
|
||||
arrivalDate: Date
|
||||
departureDate: Date
|
||||
hotelId: string
|
||||
hotelName: string
|
||||
country: string | undefined
|
||||
hotelCity: string | undefined
|
||||
paramCity: string | undefined
|
||||
bookingCode?: string
|
||||
isRedemption?: boolean
|
||||
rooms?: Room[]
|
||||
}
|
||||
|
||||
export function getSelectRateTracking({
|
||||
lang,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
hotelId,
|
||||
hotelName,
|
||||
country,
|
||||
hotelCity,
|
||||
paramCity,
|
||||
bookingCode,
|
||||
isRedemption = false,
|
||||
rooms = [],
|
||||
}: SelectRateTrackingInput) {
|
||||
const pageTrackingData: TrackingSDKPageData = {
|
||||
channel: TrackingChannelEnum.hotelreservation,
|
||||
domainLanguage: lang,
|
||||
pageId: "select-rate",
|
||||
pageName: "hotelreservation|select-rate",
|
||||
pageType: "bookingroomsandratespage",
|
||||
siteSections: "hotelreservation|select-rate",
|
||||
siteVersion: "new-web",
|
||||
}
|
||||
|
||||
let adultsInRoom: number[] = []
|
||||
let childrenInRoom: ChildrenInRoom = null
|
||||
if (rooms?.length) {
|
||||
adultsInRoom = rooms.map((room) => room.adults ?? 0)
|
||||
childrenInRoom = rooms.map((room) => room.childrenInRoom ?? null)
|
||||
}
|
||||
|
||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
ageOfChildren: childrenInRoom
|
||||
?.map((c) => c?.map((k) => k.age).join(",") ?? "")
|
||||
.join("|"),
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
childBedPreference: childrenInRoom
|
||||
?.map((c) => c?.map((k) => ChildBedMapEnum[k.bed]).join(",") ?? "")
|
||||
.join("|"),
|
||||
country,
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
hotelID: hotelId,
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
noOfAdults: adultsInRoom.join(","),
|
||||
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
|
||||
noOfRooms: rooms?.length ?? 0,
|
||||
region: hotelCity,
|
||||
searchTerm: paramCity ?? hotelName,
|
||||
searchType: "hotel",
|
||||
bookingCode: bookingCode ?? "n/a",
|
||||
rewardNight: isRedemption ? "yes" : "no",
|
||||
specialRoomType: rooms
|
||||
?.map((room) => {
|
||||
const packages = room.packages
|
||||
?.map((pkg) => {
|
||||
if (pkg === RoomPackageCodeEnum.ACCESSIBILITY_ROOM) {
|
||||
return "accessibility"
|
||||
} else if (pkg === RoomPackageCodeEnum.ALLERGY_ROOM) {
|
||||
return "allergy friendly"
|
||||
} else if (pkg === RoomPackageCodeEnum.PET_ROOM) {
|
||||
return "pet room"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
})
|
||||
.join(",")
|
||||
|
||||
return packages ?? ""
|
||||
})
|
||||
.join("|"),
|
||||
}
|
||||
|
||||
return {
|
||||
hotelsTrackingData,
|
||||
pageTrackingData,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user