Merged in fix/remove-old-select-rate (pull request #2647)

Fix/remove old select rate

* remove old select-rate

* Fix imports

* renamed SelectRate2 -> SelectRate
This commit is contained in:
Joakim Jäderberg
2025-08-13 13:43:48 +00:00
parent 51f53a717d
commit e3067331c6
127 changed files with 1859 additions and 8448 deletions

View File

@@ -1,16 +0,0 @@
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
// Select Rate loading doesn't need a layout and wrapper
// to force loading.tsx to show again since refetch of
// availability happens client-side and only the RoomCards
// display a loading state since we already have the hotel
// data
export default function LoadingSelectRate() {
return (
<>
<HotelInfoCardSkeleton />
<RoomsContainerSkeleton />
</>
)
}

View File

@@ -1,43 +0,0 @@
import { notFound } from "next/navigation"
import { parseSelectRateSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
import SelectRate from "@/components/HotelReservation/SelectRate"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
const singleRoomRateTypes = combineRegExps(
[rateTypeRegex.ARB, rateTypeRegex.VOUCHER],
"i"
)
export default async function SelectRatePage(
props: PageArgs<LangParams & { section: string }, NextSearchParams>
) {
const params = await props.params
const searchParams = await props.searchParams
const booking = parseSelectRateSearchParams(searchParams)
if (!booking) return notFound()
const isMultiRoom = booking.rooms.length > 1
const isRedemption = booking.searchType === SEARCH_TYPE_REDEMPTION
const isArbOrVoucher = booking.bookingCode
? singleRoomRateTypes.test(booking.bookingCode)
: false
if ((isMultiRoom && isRedemption) || (isMultiRoom && isArbOrVoucher)) {
return notFound()
}
// If someone tries to update the url with
// a bookingCode also, then we need to remove it
if (isRedemption && searchParams.bookingCode) {
delete searchParams.bookingCode
}
return <SelectRate lang={params.lang} booking={booking} />
}

View File

@@ -1,11 +1,6 @@
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
// Select Rate loading doesn't need a layout and wrapper
// to force loading.tsx to show again since refetch of
// availability happens client-side and only the RoomCards
// display a loading state since we already have the hotel
// data
export default function LoadingSelectRate() {
return (
<>

View File

@@ -7,7 +7,7 @@ import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { combineRegExps, rateTypeRegex } from "@/constants/booking"
import { getHotel } from "@/lib/trpc/memoizedRequests"
import SelectRate from "@/components/HotelReservation/SelectRate2"
import SelectRate from "@/components/HotelReservation/SelectRate"
import { SelectRateProvider } from "@/contexts/SelectRate/SelectRateContext"
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"

View File

@@ -14,7 +14,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
import styles from "./bottomSheet.module.css"

View File

@@ -16,7 +16,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import useLang from "@/hooks/useLang"

View File

@@ -1,12 +1,11 @@
.hotelDescription {
overflow: hidden;
text-align: left;
}
.descriptionWrapper {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
align-items: center;
}
.collapsed {
@@ -25,6 +24,7 @@
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--Space-x2);
}
.description {
@@ -34,6 +34,7 @@
.showMoreButton {
display: flex;
align-items: flex-end;
background-color: transparent;
border-width: 0;
padding: 0;
@@ -49,13 +50,16 @@
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
padding-bottom: var(--Space-x2);
}
.facilitiesItem {

View File

@@ -53,21 +53,22 @@ export default function HotelDescription({
</div>
))}
</div>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p
className={`${styles.hotelDescription} ${
expanded ? styles.expanded : styles.collapsed
}`}
>
{description}
</p>
</Typography>
<Typography variant="Link/md">
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
{expanded ? textShowLess : textShowMore}
</ButtonRAC>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p
className={`${styles.hotelDescription} ${
expanded ? styles.expanded : styles.collapsed
}`}
>
{description}
</p>
</Typography>
<Typography variant="Link/md">
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
{expanded ? textShowLess : textShowMore}
</ButtonRAC>
</Typography>
{expanded && (
<div className={styles.expandedContent}>
<ReadMore
label={intl.formatMessage({
@@ -77,17 +78,16 @@ export default function HotelDescription({
showCTA={false}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
{hotel.specialAlerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
))}
</div>
</div>
{hotel.specialAlerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
))}
)}
</div>
)
}

View File

@@ -17,13 +17,17 @@ import HotelDescription from "./HotelDescription"
import styles from "./hotelInfoCard.module.css"
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCard"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export default async function HotelInfoCard({
booking,
hotel,
}: HotelInfoCardProps) {
export type HotelInfoCardProps = {
booking: SelectRateBooking
hotel: Hotel
}
export async function HotelInfoCard({ booking, hotel }: HotelInfoCardProps) {
const intl = await getIntl()
const sortedFacilities = hotel.detailedFacilities
@@ -34,7 +38,6 @@ export default async function HotelInfoCard({
const bookingFromDate = dt(booking.fromDate)
const bookingToDate = dt(booking.toDate)
const specialAlerts = getHotelAlertsForBookingDates(
hotel.specialAlerts,
bookingFromDate,
@@ -109,22 +112,26 @@ export default async function HotelInfoCard({
</div>
</div>
</section>
{specialAlerts.map((alert) => {
return (
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
</div>
)
})}
{specialAlerts.map((alert) => (
<SpecialAlert key={alert.id} alert={alert} />
))}
</article>
)
}
function SpecialAlert({ alert }: { alert: Hotel["specialAlerts"][number] }) {
return (
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
</div>
)
}
export function HotelInfoCardSkeleton() {
return (
<article className={styles.container}>
@@ -157,7 +164,7 @@ export function HotelInfoCardSkeleton() {
>
<SkeletonShimmer width="20ch" />
</Typography>
{[1, 2, 3, 4, 5].map((id) => {
{[1, 2, 3, 4, 5]?.map((id) => {
return (
<div className={styles.facilitiesItem} key={id}>
<SkeletonShimmer width="10ch" />

View File

@@ -2,6 +2,7 @@
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"
@@ -10,59 +11,64 @@ 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 { useRatesStore } from "@/stores/select-rate"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import useLang from "@/hooks/useLang"
import { mapToPrice } from "../mapToPrice"
import { isBookingCodeRate } from "../../utils"
import Room from "../Room"
import { getMemberPrice, isBookingCodeRate } from "../utils"
import styles from "./summaryContent.module.css"
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
import type { Price } from "@/contexts/SelectRate/getTotalPrice"
export type SelectRateSummaryProps = {
isMember: boolean
bookingCode?: string
toggleSummaryOpen: () => void
}
export default function SummaryContent({
booking,
rooms,
totalPrice,
isMember,
vat,
toggleSummaryOpen,
}: SelectRateSummaryProps) {
const { rateSummary, defaultCurrency } = useRatesStore((state) => ({
rateSummary: state.rateSummary,
defaultCurrency: state.defaultCurrency,
}))
const { selectedRates, input } = useSelectRateContext()
const intl = useIntl()
const lang = useLang()
const rateTitles = useRateTitles()
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
const nightsLabel = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: diff }
{ totalNights: input.nights }
)
const filteredRooms = rooms.filter(
(room): room is NonNullable<typeof room> => !!room
)
const memberPrice =
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.roomRate)
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 = totalPrice.local?.regularPrice
? totalPrice.local.regularPrice
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice = totalRegularPrice > totalPrice.local.price
const priceDetailsRooms = mapToPrice(rateSummary, booking.rooms, isMember)
const showStrikeThroughPrice =
totalRegularPrice > selectedRates?.totalPrice?.local?.price
return (
<section className={styles.summary}>
@@ -90,26 +96,44 @@ export default function SummaryContent({
</div>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.dates}>
{dt(booking.fromDate).locale(lang).format(longDateFormat[lang])}
{dt(input.data?.booking.fromDate)
.locale(lang)
.format(longDateFormat[lang])}
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format(longDateFormat[lang])} (
{nights}){/* eslint-enable formatjs/no-literal-string-in-jsx */}
{dt(input.data?.booking.toDate)
.locale(lang)
.format(longDateFormat[lang])}{" "}
({nightsLabel})
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
</p>
</Typography>
</header>
<Divider color="Border/Divider/Subtle" />
{filteredRooms.map((room, idx) => (
<Room
key={idx}
room={room}
roomNumber={idx + 1}
roomCount={rooms.length}
isMember={isMember}
/>
))}
{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}>
@@ -130,7 +154,7 @@ export default function SummaryContent({
)}
</p>
</Typography>
{totalPrice.requested ? (
{selectedRates.totalPrice.requested ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.approxPrice}>
{intl.formatMessage(
@@ -140,10 +164,11 @@ export default function SummaryContent({
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
selectedRates.totalPrice.requested.price,
selectedRates.totalPrice.requested.currency,
selectedRates.totalPrice.requested.additionalPrice,
selectedRates.totalPrice.requested
.additionalPriceCurrency
),
}
)}
@@ -161,22 +186,22 @@ export default function SummaryContent({
>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
totalPrice.local.regularPrice ? (
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
@@ -185,18 +210,140 @@ export default function SummaryContent({
</div>
<PriceDetailsModal
bookingCode={booking.bookingCode}
defaultCurrency={defaultCurrency}
fromDate={booking.fromDate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
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={memberPrice} badgeContent={"✌️"} />
<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,
}
}

View File

@@ -49,7 +49,7 @@
}
}
.prices .strikeThroughRate {
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}

View File

@@ -8,11 +8,10 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import { getRoomPrice } from "@/stores/enter-details/helpers"
import Modal from "@/components/Modal"
import { getMemberPrice, isBookingCodeRate } from "../utils"
import { isBookingCodeRate } from "../../utils"
import { getMemberPrice } from "../utils"
import styles from "./room.module.css"
@@ -72,7 +71,6 @@ export default function Room({
const memberPrice = getMemberPrice(room.roomRate)
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
const regularRate = getRoomPrice(room.roomRate, showMemberPrice)
const adultsMsg = intl.formatMessage(
{
@@ -146,13 +144,12 @@ export default function Room({
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</p>
{/* Show the price on which discount applies as Striked when discounted price is available */}
{showDiscounted && regularRate.perStay.local.regularPrice ? (
{showDiscounted && room.roomPrice.perStay.local.price ? (
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
regularRate.perStay.local.regularPrice,
regularRate.perStay.local.currency
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</s>
) : null}

View File

@@ -18,6 +18,7 @@
.termsText:nth-child(n) {
display: flex;
align-items: center;
margin-bottom: var(--Space-x1);
}
@@ -46,7 +47,7 @@
}
}
.prices .strikeThroughRate {
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}

View File

@@ -1,370 +0,0 @@
"use client"
import { Fragment } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import { Button } from "@scandic-hotels/design-system/Button"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import { useRatesStore } from "@/stores/select-rate"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import Modal from "@/components/Modal"
import useLang from "@/hooks/useLang"
import { mapToPrice } from "./mapToPrice"
import { isBookingCodeRate } from "./utils"
import styles from "./summary.module.css"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
export default function Summary({
booking,
rooms,
totalPrice,
isMember,
vat,
toggleSummaryOpen,
}: SelectRateSummaryProps) {
const { rateSummary, defaultCurrency } = useRatesStore((state) => ({
rateSummary: state.rateSummary,
defaultCurrency: state.defaultCurrency,
}))
const intl = useIntl()
const lang = useLang()
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: diff }
)
function getMemberPrice(roomRate: RoomRate) {
if ("member" in roomRate && roomRate.member) {
return {
amount: roomRate.member.localPrice.pricePerStay,
currency: roomRate.member.localPrice.currency,
pricePerNight: roomRate.member.localPrice.pricePerNight,
}
}
return null
}
const memberPrice =
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.roomRate)
)
const showDiscounted = containsBookingCodeRate || isMember
const priceDetailsRooms = mapToPrice(rateSummary, booking.rooms, isMember)
return (
<section className={styles.summary}>
<header className={styles.header}>
<ButtonRAC onPress={toggleSummaryOpen}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(booking.fromDate).locale(lang).format(longDateFormat[lang])}
<MaterialIcon
icon="arrow_forward"
size={15}
color="Icon/Interactive/Secondary"
/>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format(longDateFormat[lang])} (
{nights}){/* eslint-enable formatjs/no-literal-string-in-jsx */}
</Body>
<MaterialIcon
className={styles.chevronIcon}
icon="keyboard_arrow_down"
size={30}
color="CurrentColor"
/>
</ButtonRAC>
</header>
<Divider color="Border/Divider/Subtle" />
{rooms.map((room, idx) => {
if (!room) {
return null
}
const roomNumber = idx + 1
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
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
return (
<Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
>
<div>
{rooms.length > 1 ? (
<Body textTransform="bold">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</Body>
) : null}
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showDiscounted ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
{guestsParts.join(", ")}
</Caption>
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Modal
trigger={
<Button
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) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</Body>
))}
</div>
</Modal>
</div>
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</Caption>
</div>
<Body color="uiTextHighContrast">{zeroPrice}</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">{zeroPrice}</Body>
</div>
) : null}
{roomPackages?.map((pkg) => (
<div className={styles.entry} key={pkg.code}>
<div>
<Body color="uiTextHighContrast">{pkg.description}</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
pkg.localPrice.price,
pkg.localPrice.currency
)}
</Body>
</div>
))}
</div>
<Divider color="Border/Divider/Subtle" />
</Fragment>
)
})}
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
bookingCode={booking.bookingCode}
fromDate={booking.fromDate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
defaultCurrency={defaultCurrency}
/>
</div>
<div>
<Body
color={showDiscounted ? "red" : "uiTextHighContrast"}
textTransform="bold"
data-testid="total-price"
>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Body>
{booking.bookingCode && totalPrice.local.regularPrice && (
<Caption color="uiTextMediumContrast" striked={true}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</Caption>
)}
{totalPrice.requested && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
),
}
)}
</Caption>
)}
</div>
</div>
<Divider
className={styles.bottomDivider}
color="Border/Divider/Subtle"
/>
</div>
{!isMember && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}

View File

@@ -9,35 +9,21 @@ 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 { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import { isBookingCodeRate } from "../utils"
import SummaryContent from "./Content"
import { mapRate } from "./mapRate"
import { isBookingCodeRate } from "./utils"
import styles from "./mobileSummary.module.css"
import type { RoomsAvailability } from "@scandic-hotels/trpc/types/roomAvailability"
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
export default function MobileSummary({
isAllRoomsSelected,
isUserLoggedIn,
totalPriceToShow,
}: MobileSummaryProps) {
export function MobileSummary() {
const intl = useIntl()
const scrollY = useRef(0)
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const isUserLoggedIn = useIsUserLoggedIn()
const { booking, bookingRooms, roomsAvailability, rateSummary, vat } =
useRatesStore((state) => ({
booking: state.booking,
bookingRooms: state.booking.rooms,
roomsAvailability: state.roomsAvailability,
rateSummary: state.rateSummary,
vat: state.vat,
}))
const { selectedRates } = useSelectRateContext()
function toggleSummaryOpen() {
setIsSummaryOpen(!isSummaryOpen)
@@ -67,38 +53,28 @@ export default function MobileSummary({
}
}, [isSummaryOpen])
const roomRateDefinitions = roomsAvailability?.find(
(ra): ra is RoomsAvailability => "rateDefinitions" in ra
const containsBookingCodeRate = selectedRates.rates.find(
(r) => r && isBookingCodeRate(r)
)
if (!roomRateDefinitions) {
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
if (!selectedRates.totalPrice) {
return null
}
const rooms = rateSummary.map((room, index) =>
room ? mapRate(room, index, bookingRooms, room.packages) : null
)
const containsBookingCodeRate = rateSummary.find(
(r) => r && isBookingCodeRate(r.product)
)
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
const totalRegularPrice = totalPriceToShow.local?.regularPrice
? totalPriceToShow.local.regularPrice
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice =
totalRegularPrice > totalPriceToShow.local.price
totalRegularPrice > selectedRates.totalPrice.local?.price
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<SummaryContent
booking={booking}
rooms={rooms}
isMember={isUserLoggedIn}
totalPrice={totalPriceToShow}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
@@ -124,22 +100,22 @@ export default function MobileSummary({
>
{formatPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
totalPriceToShow.local.regularPrice ? (
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPriceToShow.local.regularPrice,
totalPriceToShow.local.currency
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
@@ -166,7 +142,7 @@ export default function MobileSummary({
size="Large"
type="submit"
typography="Body/Paragraph/mdBold"
isDisabled={!isAllRoomsSelected}
isDisabled={selectedRates.state !== "ALL_SELECTED"}
>
{intl.formatMessage({
defaultMessage: "Continue",

View File

@@ -88,7 +88,7 @@
}
}
.priceDetailsButton .strikeThroughRate {
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}

View File

@@ -2,8 +2,8 @@
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
gap: var(--Space-x2);
padding: var(--Space-x3);
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
@@ -31,32 +31,32 @@
.date {
align-items: center;
display: flex;
gap: var(--Space-x1);
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Space-x1);
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Space-x05);
gap: var(--Spacing-x-half);
justify-content: space-between;
}
@@ -67,7 +67,7 @@
.total {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
gap: var(--Spacing-x2);
}
.bottomDivider {
@@ -79,15 +79,16 @@
}
.terms {
margin-top: var(--Space-x3);
margin-bottom: var(--Space-x3);
margin-top: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
margin-bottom: var(--Space-x1);
align-items: center;
margin-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
margin-right: var(--Space-x1);
margin-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {

View File

@@ -1,7 +1,3 @@
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
export function getMemberPrice(roomRate: RoomRate) {
@@ -15,21 +11,3 @@ export function getMemberPrice(roomRate: RoomRate) {
return null
}
export function isBookingCodeRate(product: Product) {
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
}
}

View File

@@ -1,138 +1,37 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useState, useTransition } from "react"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useRatesStore } from "@/stores/select-rate"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { isValidClientSession } from "@/utils/clientSession"
import MobileSummary from "./MobileSummary"
import { getTotalPrice } from "./utils"
import { DesktopSummary } from "./DesktopSummary"
import { MobileSummary } from "./MobileSummary"
import styles from "./rateSummary.module.css"
export default function RateSummary() {
const {
bookingCode,
bookingRooms,
dates,
isFetchingPackages,
rateSummary,
roomsAvailability,
} = useRatesStore((state) => ({
bookingCode: state.booking.bookingCode,
bookingRooms: state.booking.rooms,
dates: {
checkInDate: state.booking.fromDate,
checkOutDate: state.booking.toDate,
},
isFetchingPackages: state.rooms.some((room) => room.isFetchingPackages),
rateSummary: state.rateSummary,
roomsAvailability: state.roomsAvailability,
}))
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
export function RateSummary() {
return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<ErrorBoundary fallback={<div>Unable to render summary</div>}>
<InnerRateSummary />
</ErrorBoundary>
)
}
function InnerRateSummary() {
const { selectedRates, input } = useSelectRateContext()
const [isSubmitting, setIsSubmitting] = useState(false)
const intl = useIntl()
const router = useRouter()
const params = useSearchParams()
const [_, startTransition] = useTransition()
if (!roomsAvailability) {
if (selectedRates.state === "NONE_SELECTED") {
return null
}
const checkInDate = new Date(dates.checkInDate)
const checkOutDate = new Date(dates.checkOutDate)
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
const totalNights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
const totalAdults = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: bookingRooms.reduce((acc, room) => acc + room.adults, 0) }
)
const childrenInOneOrMoreRooms = bookingRooms.some(
(room) => room.childrenInRoom?.length
)
const childrenInroom = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{
totalChildren: bookingRooms.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: bookingRooms.length }
)
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
const totalRoomsRequired = bookingRooms.length
const isAllRoomsSelected =
rateSummary.filter((rate) => rate !== null).length === totalRoomsRequired
const hasMemberRates = rateSummary.some(
(room) => room && "member" in room.product && room.product.member
)
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
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",
})
function getRateDetails(rate: RateEnum) {
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSubmitting(true)
@@ -141,62 +40,15 @@ export default function RateSummary() {
})
}
if (!rateSummary.length || isFetchingPackages) {
return null
}
const totalPriceToShow = selectedRates.totalPrice
const isBookingCodeRate = rateSummary.some(
(rate) =>
rate &&
"public" in rate.product &&
rate.product.public?.rateType !== RateTypeEnum.Regular
)
const isVoucherRate = rateSummary.some(
(rate) => rate && "voucher" in rate.product
)
const isCorporateChequeRate = rateSummary.some(
(rate) => rate && "corporateCheque" in rate.product
)
const showDiscounted =
isUserLoggedIn ||
isBookingCodeRate ||
isVoucherRate ||
isCorporateChequeRate
const mainRoomProduct = rateSummary[0]
const totalPriceToShow = getTotalPrice(
mainRoomProduct,
rateSummary,
isUserLoggedIn,
intl
)
const rateProduct = rateSummary.find((rate) => rate?.product)?.product
if (!totalPriceToShow || !rateProduct) {
return null
}
let mainRoomCurrency = ""
if ("member" in rateProduct && rateProduct.member?.localPrice) {
mainRoomCurrency = rateProduct.member.localPrice.currency
}
if (
!mainRoomCurrency &&
"public" in rateProduct &&
rateProduct.public?.localPrice
!totalPriceToShow ||
!selectedRates.rates.some((room) => room?.isSelected ?? false)
) {
mainRoomCurrency = rateProduct.public.localPrice.currency
return null
}
const totalRegularPrice = totalPriceToShow.local?.regularPrice
? totalPriceToShow.local.regularPrice
: 0
const isTotalRegularPriceGreaterThanPrice =
totalRegularPrice > totalPriceToShow.local.price
const showStrikedThroughPrice =
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
// 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
@@ -209,218 +61,21 @@ export default function RateSummary() {
>
<div className={styles.summary}>
<div className={styles.content}>
<div className={styles.summaryText}>
{rateSummary.map((room, index) => {
if (!room) {
return (
<div key={`unselected-${index}`}>
<Subtitle color="uiTextPlaceholder">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: index + 1 }
)}
</Subtitle>
<Body color="uiTextPlaceholder">
{intl.formatMessage({
defaultMessage: "Select room",
})}
</Body>
</div>
)
}
return (
<div key={index}>
{rateSummary.length > 1 ? (
<>
<Subtitle color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: index + 1 }
)}
</Subtitle>
<Body color="uiTextMediumContrast">{room.roomType}</Body>
<Caption color="uiTextMediumContrast">
{getRateDetails(room.rate)}
</Caption>
</>
) : (
<>
<Subtitle color="uiTextHighContrast">
{room.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">
{getRateDetails(room.rate)}
</Body>
</>
)}
</div>
)
})}
{/* Render unselected rooms */}
{Array.from({
length: totalRoomsRequired - rateSummary.length,
}).map((_, index) => (
<div key={`unselected-${index}`}>
<Subtitle color="uiTextPlaceholder">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: rateSummary.length + index + 1 }
)}
</Subtitle>
<Body color="uiTextPlaceholder">
{intl.formatMessage({
defaultMessage: "Select room",
})}
</Body>
</div>
))}
</div>
<div className={styles.summaryPriceContainer}>
{showMemberDiscountBanner && (
<div className={styles.promoContainer}>
<SignupPromoDesktop
memberPrice={{
amount: rateSummary.reduce((total, rate) => {
if (!rate) {
return total
}
const { packages: roomPackages, product } = rate
const memberExists = "member" in product && product.member
const publicExists = "public" in product && product.public
if (!memberExists) {
if (!publicExists) {
return total
}
}
const price =
product.member?.localPrice.pricePerStay ||
product.public?.localPrice.pricePerStay
if (!price) {
return total
}
const selectedPackagesPrice = roomPackages.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,
totalPriceToShow.local.price,
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
{showStrikedThroughPrice &&
totalPriceToShow.local.regularPrice ? (
<Caption
textAlign="right"
color="uiTextMediumContrast"
striked={true}
>
{formatPrice(
intl,
totalPriceToShow.local.regularPrice,
totalPriceToShow.local.currency
)}
</Caption>
) : null}
{totalPriceToShow.requested ? (
<Body color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
totalPriceToShow.requested.price,
totalPriceToShow.requested.currency,
totalPriceToShow.requested.additionalPrice,
totalPriceToShow.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,
totalPriceToShow.local.price,
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.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>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<ErrorBoundary fallback={<div>Unable to render desktop summary</div>}>
<DesktopSummary
isSubmitting={isSubmitting}
input={input}
selectedRates={selectedRates}
bookingCode={input.data?.booking.bookingCode || ""}
/>
</ErrorBoundary>
</div>
<div className={styles.mobileSummary}>
<MobileSummary
isAllRoomsSelected={isAllRoomsSelected}
isUserLoggedIn={isUserLoggedIn}
totalPriceToShow={totalPriceToShow}
/>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
<MobileSummary />
</ErrorBoundary>
</div>
</div>
</form>

View File

@@ -4,7 +4,10 @@ import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { sumPackages } from "@/components/HotelReservation/utils"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type {
Product,
RedemptionProduct,
} from "@scandic-hotels/trpc/types/roomAvailability"
import type { IntlShape } from "react-intl"
import type { Price } from "@/types/components/hotelReservation/price"
@@ -23,10 +26,8 @@ export function calculateTotalPrice(
const roomNr = idx + 1
const isMainRoom = roomNr === 1
let rate
let publicRate
if (isUserLoggedIn && isMainRoom && room.product.member) {
rate = room.product.member
publicRate = room.product.public
} else if (room.product.public) {
rate = room.product.public
}
@@ -50,16 +51,10 @@ export function calculateTotalPrice(
total.local.price =
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.rateType === RateTypeEnum.Regular && publicRate) {
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice =
(total.local.regularPrice || 0) +
publicRate.localPrice.pricePerStay +
packagesPrice.local
} else {
total.local.regularPrice =
(total.local.regularPrice || 0) +
(rate.localPrice.regularPricePerStay ||
rate.localPrice.pricePerStay) +
rate.localPrice.regularPricePerStay +
packagesPrice.local
}
@@ -248,3 +243,23 @@ export function getTotalPrice(
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
}
}

View File

@@ -1,9 +1,7 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { logger } from "@scandic-hotels/common/logger"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
@@ -13,123 +11,40 @@ import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
import { useRatesStore } from "@/stores/select-rate"
import Chip from "@/components/TempDesignSystem/Chip"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import styles from "./selectedRoomPanel.module.css"
export default function SelectedRoomPanel() {
export function SelectedRoomPanel({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const { dates, roomCategories, rooms } = useRatesStore((state) => ({
dates: {
from: state.booking.fromDate,
to: state.booking.toDate,
},
roomCategories: state.roomCategories,
rooms: state.rooms,
}))
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const isMainRoom = roomIndex === 0
const roomNr = roomIndex + 1
const {
actions: { modifyRate },
isMainRoom,
roomNr,
selectedPackages,
selectedRate,
} = useRoomContext()
const nights = dt(dates.to).diff(dt(dates.from), "days")
selectedRates,
actions: { setActiveRoom },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const images = selectedRate?.roomInfo?.roomInfo?.images
const images = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some(
(roomType) => roomType.code === selectedRate?.roomTypeCode
)
)?.images
const rateTitle = useRateTitle(selectedRate?.rate)
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",
})
function getRateTitle(rate: RateEnum) {
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}
const selectedProductTitle = useSelectedProductTitle({ roomIndex })
if (!selectedRate) {
return null
}
const selectedPackagesCurrency = selectedPackages.find(
(pkg) => pkg.localPrice.currency
)
const selectedPackagesPrice = selectedPackages.reduce(
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const selectedPackagesPricePerNight = Math.ceil(
selectedPackagesPrice / nights
)
const night = intl.formatMessage({
defaultMessage: "night",
})
let selectedProduct
if (
isUserLoggedIn &&
isMainRoom &&
"member" in selectedRate.product &&
selectedRate.product.member
) {
const { localPrice } = selectedRate.product.member
selectedProduct = `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
} else if ("public" in selectedRate.product && selectedRate.product.public) {
const { localPrice } = selectedRate.product.public
selectedProduct = `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
} else if ("corporateCheque" in selectedRate.product) {
const { localPrice } = selectedRate.product.corporateCheque
selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
localPrice.currency
) {
selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
}
} else if ("voucher" in selectedRate.product) {
selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
if (selectedPackagesPrice && selectedPackagesCurrency) {
selectedProduct = `${selectedProduct} + ${selectedPackagesPrice} ${selectedPackagesCurrency}`
}
}
if (!selectedProduct) {
if (!selectedProductTitle) {
logger.error("Selected product is unknown")
return null
}
const showModifyButton =
isMainRoom ||
(!isMainRoom && rooms.slice(0, roomNr).every((room) => room.selectedRate))
(!isMainRoom && selectedRates.rates.slice(0, roomNr).every((room) => room))
return (
<div className={styles.selectedRoomPanel}>
@@ -143,17 +58,19 @@ export default function SelectedRoomPanel() {
)}
</Caption>
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{selectedRate.roomType}
{selectedRate.roomInfo.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">
{getRateTitle(selectedRate.product.rate)}
</Body>
<Body color="uiTextHighContrast">{selectedProduct}</Body>
<Body color="uiTextMediumContrast">{rateTitle}</Body>
<Body color="uiTextHighContrast">{selectedProductTitle}</Body>
</div>
<div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny ? (
<Image
alt={selectedRate.roomType ?? images[0].metaData?.altText ?? ""}
alt={
selectedRate.roomInfo.roomType ??
images[0].metaData?.altText ??
""
}
className={styles.img}
height={300}
src={images[0].imageSizes.tiny}
@@ -162,7 +79,7 @@ export default function SelectedRoomPanel() {
) : null}
{showModifyButton && (
<div className={styles.modifyButtonContainer}>
<Button clean onClick={modifyRate}>
<Button clean onClick={() => setActiveRoom(roomIndex)}>
<Chip size="small" variant="uiTextHighContrast">
<MaterialIcon
size={16}
@@ -180,3 +97,99 @@ export default function SelectedRoomPanel() {
</div>
)
}
function useSelectedProductTitle({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const isUserLoggedIn = useIsUserLoggedIn()
const {
selectedRates,
input: { nights },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const night = intl.formatMessage({
defaultMessage: "night",
})
const isMainRoom = roomIndex === 0
if (!selectedRate) {
return null
}
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
(pkg) => pkg.localPrice.currency
)
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const selectedPackagesPricePerNight = Math.ceil(
selectedPackagesPrice / nights
)
if (
isUserLoggedIn &&
isMainRoom &&
"member" in selectedRate &&
selectedRate.member
) {
const { localPrice } = selectedRate.member
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("public" in selectedRate && selectedRate.public) {
const { localPrice } = selectedRate.public
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("corporateCheque" in selectedRate) {
const { localPrice } = selectedRate.corporateCheque
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
localPrice.currency
) {
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
return `${mainProductTitle} + ${packagesText}`
}
}
if ("voucher" in selectedRate) {
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
if (selectedPackagesPrice && selectedPackagesCurrency) {
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
return `${mainProductText} + ${packagesText}`
}
}
}
function useRateTitle(rate: RateEnum | undefined) {
const intl = useIntl()
const freeCancelation = intl.formatMessage({
defaultMessage: "Free cancellation",
})
const nonRefundable = intl.formatMessage({
defaultMessage: "Non-refundable",
})
const freeBooking = intl.formatMessage({
defaultMessage: "Free rebooking",
})
const payLater = intl.formatMessage({
defaultMessage: "Pay later",
})
const payNow = intl.formatMessage({
defaultMessage: "Pay now",
})
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}

View File

@@ -6,29 +6,32 @@ 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 { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import SelectedRoomPanel from "./SelectedRoomPanel"
import { SelectedRoomPanel } from "./SelectedRoomPanel"
import { roomSelectionPanelVariants } from "./variants"
import styles from "./multiRoomWrapper.module.css"
export default function MultiRoomWrapper({
children,
isMultiRoom,
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
type Props = {
children: React.ReactNode
isMultiRoom: boolean
roomIndex: number
}
export function MultiRoomWrapper({ children, isMultiRoom, roomIndex }: Props) {
const intl = useIntl()
const activeRoom = useRatesStore((state) => state.activeRoom)
const {
actions: { closeSection },
bookingRoom,
isActiveRoom,
roomNr,
selectedRate,
} = useRoomContext()
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(
{
@@ -41,7 +44,7 @@ export default function MultiRoomWrapper({
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{ adults: bookingRoom.adults }
{ adults: adultCount }
)
const childrenMsg = intl.formatMessage(
@@ -49,15 +52,13 @@ export default function MultiRoomWrapper({
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{
children: bookingRoom.childrenInRoom?.length,
children: childCount,
}
)
const onlyAdultsMsg = adultsMsg
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
const guestsMsg = bookingRoom.childrenInRoom?.length
? adultsAndChildrenMsg
: onlyAdultsMsg
const guestsMsg = childCount ? adultsAndChildrenMsg : onlyAdultsMsg
const title = [roomMsg, guestsMsg].join(", ")
@@ -69,7 +70,7 @@ export default function MultiRoomWrapper({
// If no room is active we will show all rooms collapsed, hence we want
// to scroll to the first room.
const selectedRoom =
activeRoom === -1 ? roomElements[0] : roomElements[activeRoom]
activeRoomIndex === -1 ? roomElements[0] : roomElements[activeRoomIndex]
if (selectedRoom) {
const elementPosition = selectedRoom.getBoundingClientRect().top
@@ -86,7 +87,9 @@ export default function MultiRoomWrapper({
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRoom])
}, [activeRoomIndex])
const selectedRate = selectedRates.rateSelectedForRoom(roomIndex)
if (isMultiRoom) {
const classNames = roomSelectionPanelVariants({
@@ -102,7 +105,9 @@ export default function MultiRoomWrapper({
{selectedRate && isActiveRoom ? (
<Button
intent="text"
onClick={closeSection}
onClick={() => {
setActiveRoom("deselect")
}}
size="medium"
theme="base"
variant="icon"
@@ -120,7 +125,7 @@ export default function MultiRoomWrapper({
</div>
<div className={classNames}>
<div className={styles.roomPanel}>
<SelectedRoomPanel />
<SelectedRoomPanel roomIndex={roomIndex} />
</div>
<div className={styles.roomSelectionPanel}>{children}</div>
</div>

View File

@@ -5,31 +5,36 @@ import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotel
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
import { useRatesStore } from "@/stores/select-rate"
import Alert from "@/components/TempDesignSystem/Alert"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useLang from "@/hooks/useLang"
import styles from "./alert.module.css"
export default function NoAvailabilityAlert() {
export default function NoAvailabilityAlert({
roomIndex,
}: {
roomIndex: number
}) {
const lang = useLang()
const intl = useIntl()
const [bookingCode, selectedRooms, activeRoom] = useRatesStore((state) => [
state.booking.bookingCode,
state.rooms,
state.activeRoom,
])
const { isFetchingPackages, rooms } = useRoomContext()
const { availability, input } = useSelectRateContext()
if (availability.isFetching || !availability.data) {
return null
}
const noAvailableRooms = rooms.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
const indexed = availability.data[roomIndex]
const hasAvailabilityError = "error" in indexed
if (hasAvailabilityError) {
return null
}
const noAvailableRooms = hasAvailableRoomsForRoom(indexed.roomConfigurations)
const alertLink =
activeRoom !== -1 && selectedRooms[activeRoom].selectedPackages.length === 0
roomIndex !== -1 &&
(input.data?.booking.rooms.at(roomIndex)?.packages ?? []).length === 0
? {
title: intl.formatMessage({
defaultMessage: "See alternative hotels",
@@ -39,10 +44,6 @@ export default function NoAvailabilityAlert() {
}
: null
if (isFetchingPackages) {
return null
}
if (noAvailableRooms) {
const text = intl.formatMessage({
defaultMessage: "There are no rooms available that match your request.",
@@ -61,7 +62,7 @@ export default function NoAvailabilityAlert() {
)
}
const isPublicPromotionWithCode = rooms.some((room) => {
const isPublicPromotionWithCode = indexed.roomConfigurations.some((room) => {
const filteredCampaigns = room.campaign.filter(Boolean)
return filteredCampaigns.length
? filteredCampaigns.every(
@@ -72,19 +73,20 @@ export default function NoAvailabilityAlert() {
const noAvailableBookingCodeRooms =
!isPublicPromotionWithCode &&
rooms.every(
indexed.roomConfigurations.every(
(room) =>
room.status === AvailabilityEnum.NotAvailable || !room.code.length
)
if (bookingCode && noAvailableBookingCodeRooms) {
if (input.bookingCode && noAvailableBookingCodeRooms) {
const bookingCodeText = intl.formatMessage(
{
defaultMessage:
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
},
{ bookingCode }
{ bookingCode: input.bookingCode }
)
return (
<div className={styles.hotelAlert}>
<Alert
@@ -101,3 +103,16 @@ export default function NoAvailabilityAlert() {
return null
}
function hasAvailableRoomsForRoom(
roomConfigurations: Extract<
NonNullable<
ReturnType<typeof useSelectRateContext>["availability"]["data"]
>[number],
{ roomConfigurations: unknown }
>["roomConfigurations"]
) {
return roomConfigurations.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
}

View File

@@ -1,14 +1,12 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useRatesStore } from "@/stores/select-rate"
import BookingCodeChip from "@/components/BookingCodeChip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
export function RemoveBookingCodeButton() {
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
const roomNr = useRatesStore((state) =>
state.activeRoom !== -1 ? state.activeRoom : 0
)
const {
input: { bookingCode },
} = useSelectRateContext()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
@@ -26,9 +24,6 @@ export function RemoveBookingCodeButton() {
onClose={() => {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete("bookingCode")
newSearchParams.delete(`room[${roomNr}].bookingCode`)
newSearchParams.delete(`room[${roomNr}].ratecode`)
newSearchParams.delete(`room[${roomNr}].roomtype`)
const url = `${pathname}?${newSearchParams.toString()}`

View File

@@ -1,19 +1,23 @@
"use client"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import styles from "./petRoom.module.css"
export default function PetRoomMessage() {
export default function PetRoomMessage({
priceData,
}: {
priceData?: { price: number; currency: string }
}) {
const intl = useIntl()
const { petRoomPackage } = useRoomContext()
if (!petRoomPackage) {
if (!priceData) {
return null
}
return (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.additionalInformation}>
@@ -28,11 +32,7 @@ export default function PetRoomMessage() {
<span className={styles.additionalInformationPrice}>{str}</span>
</Typography>
),
price: formatPrice(
intl,
petRoomPackage.localPrice.price,
petRoomPackage.localPrice.currency
),
price: formatPrice(intl, priceData.price, priceData.currency),
}
)}
</p>

View File

@@ -4,25 +4,29 @@ 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 { useRatesStore } from "@/stores/select-rate"
import { usePackageLabels } from "../../usePackageLabels"
import { getIconNameByPackageCode } from "../../utils"
import PetRoomMessage from "./PetRoomMessage"
import {
checkIsAllergyRoom,
checkIsPetRoom,
includesAllergyRoom,
includesPetRoom,
} 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 default function Checkboxes() {
const packageOptions = useRatesStore((state) => state.packageOptions)
export function PackageCheckboxes({
availablePackages,
}: {
availablePackages: {
code: RoomPackageCodeEnum
message?: ReactNode
}[]
}) {
const { control } = useFormContext<FormValues>()
const packageLabels = usePackageLabels()
return (
<Controller
control={control}
@@ -32,7 +36,7 @@ export default function Checkboxes() {
const petRoomSelected = includesPetRoom(field.value)
return (
<CheckboxGroup {...field} className={styles.checkboxGroup}>
{packageOptions.map((option) => {
{availablePackages?.map((option) => {
const isAllergyRoom = checkIsAllergyRoom(option.code)
const isPetRoom = checkIsPetRoom(option.code)
const isDisabled =
@@ -59,13 +63,13 @@ export default function Checkboxes() {
className={styles.text}
variant="Body/Paragraph/mdRegular"
>
<span>{option.description}</span>
<span>{packageLabels[option.code]}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</Checkbox>
{isPetRoom ? <PetRoomMessage /> : null}
{option.message}
</div>
)
})}
@@ -75,3 +79,23 @@ export default function Checkboxes() {
/>
)
}
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
}

View File

@@ -1,19 +0,0 @@
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
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) {
return code === RoomPackageCodeEnum.ALLERGY_ROOM
}
export function checkIsPetRoom(code: PackageEnum) {
return code === RoomPackageCodeEnum.PET_ROOM
}

View File

@@ -5,76 +5,53 @@ 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 { trpc } from "@scandic-hotels/trpc/client"
import { useRatesStore } from "@/stores/select-rate"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import Checkboxes from "./Checkboxes"
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 default function Form({ close }: { close: () => void }) {
export function RoomPackagesForm({
close,
selectedPackages,
onSelectPackages,
availablePackages,
}: {
close: () => void
availablePackages: {
code: RoomPackageCodeEnum
message: ReactNode
}[]
selectedPackages: PackageEnum[]
onSelectPackages: (packages: PackageEnum[]) => void
}) {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const {
actions: { removeSelectedPackages, selectPackages, updateRooms },
bookingRoom,
selectedPackages,
} = useRoomContext()
const booking = useRatesStore((state) => state.booking)
const methods = useForm<FormValues>({
values: {
selectedPackages: selectedPackages.map((pkg) => pkg.code),
selectedPackages: selectedPackages,
},
})
async function getFilteredRates(packages: PackageEnum[]) {
const bookingCode = bookingRoom.rateCode
? bookingRoom.bookingCode
: booking.bookingCode
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
booking: {
fromDate: booking.fromDate,
hotelId: booking.hotelId,
searchType: booking.searchType,
toDate: booking.toDate,
room: {
...bookingRoom,
bookingCode: bookingCode ?? undefined,
packages,
},
},
lang,
})
updateRooms(filterRates?.roomConfigurations)
}
function clearSelectedPackages() {
removeSelectedPackages()
onSelectPackages([])
close()
getFilteredRates([])
}
function onSubmit(data: FormValues) {
selectPackages(data.selectedPackages)
onSelectPackages(data.selectedPackages)
close()
getFilteredRates(data.selectedPackages)
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Checkboxes />
<PackageCheckboxes availablePackages={availablePackages} />
<div className={styles.footer}>
<Divider color="Border/Divider/Subtle" className={styles.divider} />
<div className={styles.buttonContainer}>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"
import { type ReactNode, useState } from "react"
import {
Dialog,
DialogTrigger,
@@ -12,11 +12,25 @@ 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 Form from "./Form"
import { RoomPackagesForm } from "./Form"
import styles from "./roomPackageFilter.module.css"
export default function RoomPackageFilterModal() {
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)
@@ -48,7 +62,12 @@ export default function RoomPackageFilterModal() {
<MaterialIcon icon="close" size={24} color="CurrentColor" />
</IconButton>
</div>
<Form close={() => setIsOpen(false)} />
<RoomPackagesForm
close={() => setIsOpen(false)}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
onSelectPackages={onSelectPackages}
/>
</Dialog>
</Modal>
</ModalOverlay>

View File

@@ -1,15 +1,29 @@
import { useState } from "react"
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 Form from "./Form"
import { RoomPackagesForm } from "./Form"
import styles from "./roomPackageFilter.module.css"
export default function RoomPackageFilterPopover() {
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)
@@ -25,8 +39,13 @@ export default function RoomPackageFilterPopover() {
</ChipButton>
<Popover placement="bottom end" className={styles.popover}>
<Dialog className={styles.popoverDialog}>
<Form close={() => setIsOpen(false)} />
<Dialog>
<RoomPackagesForm
close={() => setIsOpen(false)}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
onSelectPackages={onSelectPackages}
/>
</Dialog>
</Popover>
</DialogTrigger>

View File

@@ -1,64 +1,70 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import { useMediaQuery } from "usehooks-ts"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useBreakpoint } from "@/hooks/useBreakpoint"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import RoomPackageFilterModal from "./Modal"
import RoomPackageFilterPopover from "./Popover"
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 default function RoomPackageFilter() {
const lang = useLang()
const utils = trpc.useUtils()
const displayAsPopover = useMediaQuery("(min-width: 768px)")
export function RoomPackageFilter({ roomIndex }: { roomIndex: number }) {
const displayAsModal = useBreakpoint("mobile")
const {
actions: { removeSelectedPackage, updateRooms },
bookingRoom,
selectedPackages,
} = useRoomContext()
const { booking, packageOptions } = useRatesStore((state) => ({
booking: state.booking,
packageOptions: state.packageOptions,
}))
getPackagesForRoom,
actions: { selectPackages },
} = useSelectRateContext()
async function deleteSelectedPackage(code: PackageEnum) {
removeSelectedPackage(code)
const bookingCode = bookingRoom.rateCode
? bookingRoom.bookingCode
: booking.bookingCode
const { selectedPackages, availablePackages } = getPackagesForRoom(roomIndex)
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
booking: {
fromDate: booking.fromDate,
hotelId: booking.hotelId,
searchType: booking.searchType,
toDate: booking.toDate,
room: {
...bookingRoom,
bookingCode: bookingCode ?? undefined,
packages: selectedPackages
.filter((pkg) => pkg.code !== code)
.map((pkg) => pkg.code),
},
},
lang,
function deletePackage(code: PackageEnum) {
selectPackages({
roomIndex,
packages: selectedPackages
.filter((pkg) => pkg.code !== code)
.map((pkg) => pkg.code),
})
updateRooms(filterRates?.roomConfigurations)
}
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}>
@@ -73,12 +79,9 @@ export default function RoomPackageFilter() {
size={16}
color="CurrentColor"
/>
{
packageOptions.find((pkgOption) => pkg.code === pkgOption.code)
?.description
}
{packageLabels[pkg.code] ?? pkg.description}
<ButtonRAC
onPress={() => deleteSelectedPackage(pkg.code)}
onPress={() => deletePackage(pkg.code)}
className={styles.removeButton}
>
<MaterialIcon icon="close" size={16} color="CurrentColor" />
@@ -87,12 +90,45 @@ export default function RoomPackageFilter() {
</Typography>
))}
</div>
<div hidden={displayAsPopover}>
<RoomPackageFilterModal />
</div>
<div hidden={!displayAsPopover}>
<RoomPackageFilterPopover />
</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,
})

View File

@@ -1,24 +1,52 @@
"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 { useRoomContext } from "@/contexts/SelectRate/Room"
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
import RoomPackageFilter from "./RoomPackageFilter"
import { RoomPackageFilter } from "./RoomPackageFilter"
import styles from "./roomsHeader.module.css"
export default function RoomsHeader() {
const { isFetchingPackages, rooms, totalRooms } = useRoomContext()
const intl = useIntl()
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
<InnerRoomsHeader roomIndex={roomIndex} />
</ErrorBoundary>
)
}
const availableRooms = rooms.filter(
(room) => room.status === AvailabilityEnum.Available
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:
@@ -40,23 +68,17 @@ export default function RoomsHeader() {
}
)
if (isFetching) {
return <SkeletonShimmer height="30px" width="25ch" />
}
return (
<div className={styles.container}>
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
{isFetchingPackages ? (
<p></p>
) : (
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
)}
</Typography>
<div className={styles.filters}>
<RemoveBookingCodeButton />
<RoomPackageFilter />
</div>
</div>
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
</Typography>
)
}

View File

@@ -3,21 +3,20 @@ import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRatesStore } from "@/stores/select-rate"
import RoomSize from "./RoomSize"
import styles from "./details.module.css"
export default function Details({ roomTypeCode }: { roomTypeCode: string }) {
import type { RoomInfo } from "@/contexts/SelectRate/types"
type Props = {
roomInfo: RoomInfo
}
export default function Details({ roomInfo }: Props) {
const intl = useIntl()
const roomCategories = useRatesStore((state) => state.roomCategories)
const selectedRoom = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
)
const { name, occupancy, roomSize } = selectedRoom || {}
const { name, occupancy, roomSize } = roomInfo || {}
return (
<>

View File

@@ -6,30 +6,32 @@ import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/booki
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRatesStore } from "@/stores/select-rate"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { isValidClientSession } from "@/utils/clientSession"
import { getBreakfastMessage } from "./getBreakfastMessage"
import styles from "./breakfastMessage.module.css"
export default function BreakfastMessage({
export function BreakfastMessage({
breakfastIncludedMember,
breakfastIncludedStandard,
hasRegularRates,
roomIndex,
}: {
breakfastIncludedMember: boolean
breakfastIncludedStandard: boolean
hasRegularRates: boolean
roomIndex: number
}) {
const intl = useIntl()
const { roomNr, selectedFilter } = useRoomContext()
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 { hotelType } = useRatesStore((state) => ({
hotelType: state.hotelType,
}))
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)

View File

@@ -1,5 +1,4 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
@@ -10,33 +9,34 @@ import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import { isValidClientSession } from "@/utils/clientSession"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import { isSelectedPriceProduct } from "./isSelected"
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type {
AvailabilityWithRoomInfo,
Package,
} from "@/contexts/SelectRate/types"
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
interface CampaignProps extends SharedRateCardProps {
campaign: PriceProduct[]
type CampaignProps = {
nights: number
campaign: AvailabilityWithRoomInfo["campaign"]
roomIndex: number
roomTypeCode: string
selectedPackages: Package[]
}
export default function Campaign({
campaign,
handleSelectRate,
roomIndex,
nights,
roomTypeCode,
selectedPackages,
}: CampaignProps) {
const intl = useIntl()
const { roomNr, selectedFilter, selectedPackages, selectedRate } =
useRoomContext()
const rateTitles = useRateTitles()
const { data: session } = useSession()
const isPrimaryRoomAndLoggedIn = isValidClientSession(session) && roomNr === 1
// 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) =>
@@ -47,19 +47,52 @@ export default function Campaign({
return null
}
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
campaign = campaign.filter((product) => product.bookingCode)
}
return campaign.map((product, ix) => {
return (
<Inner
key={ix}
product={product}
nights={nights}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
selectedPackages={selectedPackages}
/>
)
})
}
function Inner({
product,
roomIndex,
roomTypeCode,
selectedPackages,
nights,
}: {
roomIndex: number
nights: number
roomTypeCode: string
product: AvailabilityWithRoomInfo["campaign"][number]
selectedPackages: Package[]
}) {
const roomNr = roomIndex + 1
const {
isRateSelected,
actions: { selectRate },
} = useSelectRateContext()
const rateTitles = useRateTitles()
const isUserLoggedIn = useIsUserLoggedIn()
const intl = useIntl()
const night = intl
.formatMessage({
defaultMessage: "night",
})
.toUpperCase()
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
campaign = campaign.filter((product) => product.bookingCode)
}
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const standardPriceMsg = intl.formatMessage({
defaultMessage: "Standard price",
})
@@ -68,165 +101,184 @@ export default function Campaign({
defaultMessage: "Member price",
})
return campaign.map((product) => {
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 = isSelectedPriceProduct(
product,
selectedRate,
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 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
let approximateRatePrice = undefined
if (isPrimaryRoomAndLoggedIn && 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 campaignMemberLabel =
product.rateDefinitionMember?.title || memberPriceMsg
if (!product.public) {
return (
<CampaignRateCard
<NoRateAvailableCard
key={product.rate}
approximateRate={approximateRate}
bannerText={bannerText}
handleChange={() => handleSelectRate(product)}
isSelected={isSelected}
isHighlightedRate={
!!product.rateDefinition?.displayPriceRed || isPrimaryRoomAndLoggedIn
}
memberRate={
pricePerNightMember && !isPrimaryRoomAndLoggedIn
? {
label: memberPriceMsg,
price: pricePerNightMember.totalPrice,
unit: `${product.member!.localPrice.currency}/${night}`,
}
: undefined
}
comparisonRate={
isPrimaryRoomAndLoggedIn
? {
price: pricePerNight.totalPrice,
unit: product.public.localPrice.currency,
}
: undefined
}
name={`rateCode-${roomNr}-${product.public.rateCode}`}
noPricesAvailableText={rateTitles.noPriceAvailable}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: isPrimaryRoomAndLoggedIn
? campaignMemberLabel
: standardPriceMsg,
price:
isPrimaryRoomAndLoggedIn && 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}
variant="Campaign"
/>
)
}
const rateTermDetails = product.rateDefinitionMember
? [
{
title: product.bookingCode
? product.rateDefinition.title
: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
{
title: product.bookingCode
? product.rateDefinitionMember.title
: memberPriceMsg,
terms: product.rateDefinitionMember.generalTerms,
},
]
: [
{
title: product.bookingCode
? product.rateDefinition.title
: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
]
const isSelected = isRateSelected({
roomIndex,
rate: { ...product, type: "campaign" },
roomTypeCode,
})
let bannerText = intl.formatMessage({
defaultMessage: "Campaign",
})
if (product.bookingCode) {
bannerText = product.bookingCode
}
if (product.rateDefinition.breakfastIncluded) {
bannerText = `${bannerText}${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
} else {
bannerText = `${bannerText}${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`
}
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const pricePerNight = calculatePricePerNightPriceProduct(
product.public.localPrice.pricePerNight,
product.public.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
const pricePerNightMember = product.member
? calculatePricePerNightPriceProduct(
product.member.localPrice.pricePerNight,
product.member.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
: undefined
const isMainRoom = roomIndex === 0
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
let approximateRatePrice = undefined
if (isMainRoomAndLoggedIn && pricePerNightMember) {
approximateRatePrice = pricePerNightMember.totalRequestedPrice
} else if (
pricePerNight.totalRequestedPrice &&
pricePerNightMember?.totalRequestedPrice
) {
approximateRatePrice = `${pricePerNight.totalRequestedPrice}/${pricePerNightMember.totalRequestedPrice}`
} else if (pricePerNight.totalRequestedPrice) {
approximateRatePrice = pricePerNight.totalRequestedPrice
}
const approximateRate =
approximateRatePrice && product.public.requestedPrice
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: approximateRatePrice,
unit: product.public.requestedPrice.currency,
}
: undefined
const rateCode = isMainRoomAndLoggedIn
? product.member!.rateCode
: product.public!.rateCode
const counterRateCode = isMainRoomAndLoggedIn
? product.public?.rateCode
: product.member?.rateCode
const campaignMemberLabel =
product.rateDefinitionMember?.title || memberPriceMsg
return (
<CampaignRateCard
key={product.rate}
approximateRate={approximateRate}
bannerText={bannerText}
handleChange={() =>
selectRate({
roomIndex,
rateCode: rateCode,
counterRateCode: counterRateCode,
roomTypeCode,
bookingCode: product.bookingCode,
})
}
isSelected={isSelected}
isHighlightedRate={
!!product.rateDefinition?.displayPriceRed || isMainRoomAndLoggedIn
}
memberRate={
pricePerNightMember && !isMainRoomAndLoggedIn
? {
label: memberPriceMsg,
price: pricePerNightMember.totalPrice,
unit: `${product.member!.localPrice.currency}/${night}`,
}
: undefined
}
comparisonRate={
isMainRoomAndLoggedIn
? {
price: pricePerNight.totalPrice,
unit: product.public.localPrice.currency,
}
: undefined
}
name={`rateCode-${roomNr}-${product.public.rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: isMainRoomAndLoggedIn ? campaignMemberLabel : standardPriceMsg,
price:
isMainRoomAndLoggedIn && pricePerNightMember
? pricePerNightMember.totalPrice
: pricePerNight.totalPrice,
unit: `${product.public.localPrice.currency}/${night}`,
}}
rateTitle={rateTitles[product.rate].title}
omnibusRate={
product.public.localPrice.omnibusPricePerNight
? {
label: intl
.formatMessage({
defaultMessage: "Lowest price (last 30 days)",
})
.toUpperCase(),
price: product.public.localPrice.omnibusPricePerNight.toString(),
unit: product.public.localPrice.currency,
}
: undefined
}
rateTermDetails={rateTermDetails}
value={product.public.rateCode}
/>
)
}

View File

@@ -4,230 +4,387 @@ import { useIntl } from "react-intl"
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
import { useRatesStore } from "@/stores/select-rate"
import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import {
isSelectedCorporateCheque,
isSelectedPriceProduct,
isSelectedVoucher,
} from "./isSelected"
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
import type { Package } from "@/contexts/SelectRate/types"
interface CodeProps extends SharedRateCardProps {
type CodeProps = {
nights: number
roomTypeCode: string
code: CodeProduct[]
roomIndex: number
selectedPackages: Package[]
}
export default function Code({
code,
handleSelectRate,
nights,
roomTypeCode,
roomIndex,
selectedPackages,
}: CodeProps) {
return code.map((product) => {
return (
<InnerCode
key={product.rate}
codeProduct={product}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
nights={nights}
selectedPackages={selectedPackages}
/>
)
})
}
function InnerCode({
codeProduct,
roomIndex,
roomTypeCode,
nights,
selectedPackages,
}: {
codeProduct: CodeProduct
roomIndex: number
roomTypeCode: string
nights: number
selectedPackages: Package[]
}) {
const {
input: { bookingCode },
actions: { selectRate },
isRateSelected,
} = useSelectRateContext()
function handleSelectRate(rateCode: string) {
selectRate({ roomIndex, rateCode, roomTypeCode })
}
const bannerText = useBannerText({
bookingCode: bookingCode ?? "",
breakfastIncluded: codeProduct.rateDefinition.breakfastIncluded,
})
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const isSelected = isRateSelected({
roomIndex,
roomTypeCode,
rate: { ...codeProduct, type: "code" },
})
if ("corporateCheque" in codeProduct) {
return (
<CorporateChequeCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
if ("voucher" in codeProduct) {
return (
<VoucherCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
if (codeProduct.public) {
return (
<PublicCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
packagesSumRequested={pkgsSumRequested}
nights={nights}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
return null
}
function useBannerText({
bookingCode,
breakfastIncluded,
}: {
breakfastIncluded: boolean
bookingCode: string
}) {
const intl = useIntl()
if (breakfastIncluded) {
return `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
} else {
return `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`
}
}
function CorporateChequeCode({
codeProduct,
roomIndex,
bannerText,
packagesSum,
handleSelectRate,
isSelected,
}: {
codeProduct: Extract<CodeProduct, { corporateCheque: any }>
roomIndex: number
roomTypeCode: string
bannerText: string
packagesSum: ReturnType<typeof sumPackages>
handleSelectRate: (rateCode: string) => void
isSelected: boolean
}) {
const roomNr = roomIndex + 1
const intl = useIntl()
const { roomNr, selectedPackages, selectedRate } = useRoomContext()
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
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()
return code.map((product) => {
let bannerText = ""
if (product.rateDefinition.breakfastIncluded) {
bannerText = `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
} else {
bannerText = `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`
}
const rateTermDetails = product.rateDefinitionMember
? [
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
{
title: product.rateDefinitionMember.title,
terms: product.rateDefinitionMember.generalTerms,
},
]
: [
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
]
const { localPrice, rateCode, requestedPrice } = codeProduct.public
const pricePerNight = calculatePricePerNightPriceProduct(
localPrice.pricePerNight,
requestedPrice?.pricePerNight,
nights,
packagesSum.price,
packagesSumRequested.price
)
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const approximateRate =
pricePerNight.totalRequestedPrice && requestedPrice?.currency
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: pricePerNight.totalRequestedPrice,
unit: requestedPrice.currency,
}
: undefined
if ("corporateCheque" in product) {
const { localPrice, rateCode, requestedPrice } = product.corporateCheque
let price = `${localPrice.numberOfCheques} CC`
if (localPrice.additionalPricePerStay) {
price = `${price} + ${localPrice.additionalPricePerStay + pkgsSum.price}`
} else if (pkgsSum.price) {
price = `${price} + ${pkgsSum.price}`
}
const regularPricePerNight = calculatePricePerNightPriceProduct(
localPrice.regularPricePerNight,
requestedPrice?.regularPricePerNight,
nights,
packagesSum.price,
packagesSumRequested.price
)
const isSelected = isSelectedCorporateCheque(
product,
selectedRate,
roomTypeCode
)
const comparisonRate =
+regularPricePerNight.totalPrice > +pricePerNight.totalPrice
? {
price: regularPricePerNight.totalPrice,
unit: localPrice.currency,
}
: undefined
const currency =
localPrice.additionalPricePerStay > 0 || pkgsSum.price > 0
? (localPrice.currency ?? pkgsSum.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={product.rate}
approximateRate={approximateRate}
bannerText={bannerText}
handleChange={() => handleSelectRate(product)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: product.rateDefinition?.title,
price,
unit: currency,
}}
rateTitle={rateTitles[product.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
if ("voucher" in product) {
const { numberOfVouchers, rateCode } = product.voucher
const isSelected = isSelectedVoucher(product, selectedRate, roomTypeCode)
const voucherMsg = intl
.formatMessage({
defaultMessage: "Voucher",
})
.toUpperCase()
let price = `${numberOfVouchers} ${voucherMsg}`
if (pkgsSum.price) {
price = `${price} + ${pkgsSum.price}`
}
return (
<CodeRateCard
key={product.rate}
bannerText={bannerText}
handleChange={() => handleSelectRate(product)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: product.rateDefinition?.title,
price,
unit: pkgsSum.currency ?? "",
}}
rateTitle={rateTitles[product.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
if (product.public) {
const { localPrice, rateCode, requestedPrice } = product.public
const pricePerNight = calculatePricePerNightPriceProduct(
localPrice.pricePerNight,
requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.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,
pkgsSum.price,
pkgsSumRequested.price
)
const comparisonRate =
+regularPricePerNight.totalPrice > +pricePerNight.totalPrice
? {
price: regularPricePerNight.totalPrice,
unit: localPrice.currency,
}
: undefined
const isSelected = isSelectedPriceProduct(
product,
selectedRate,
roomTypeCode
)
return (
<CodeRateCard
key={product.rate}
approximateRate={approximateRate}
bannerText={bannerText}
comparisonRate={comparisonRate}
handleChange={() => handleSelectRate(product)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: product.rateDefinition?.title,
price: pricePerNight.totalPrice,
unit: `${localPrice.currency}/${night}`,
}}
rateTitle={rateTitles[product.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
return null
})
return (
<CodeRateCard
key={codeProduct.rate}
approximateRate={approximateRate}
bannerText={bannerText}
comparisonRate={comparisonRate}
handleChange={() => handleSelectRate(codeProduct.public!.rateCode)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
rate={{
label: codeProduct.rateDefinition?.title,
price: pricePerNight.totalPrice,
unit: `${localPrice.currency}/${night}`,
}}
rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
function VoucherCode({
codeProduct,
bannerText,
packagesSum,
roomIndex,
handleSelectRate,
isSelected,
}: {
codeProduct: Extract<CodeProduct, { voucher: any }>
roomIndex: number
roomTypeCode: string
bannerText: string
packagesSum: ReturnType<typeof sumPackages>
handleSelectRate: (rateCode: string) => void
isSelected: boolean
}) {
const roomNr = roomIndex + 1
const intl = useIntl()
const rateTitles = useRateTitles()
const { numberOfVouchers, rateCode } = codeProduct.voucher
const rateTermDetails = getRateTermDetails(codeProduct)
const voucherMsg = intl
.formatMessage({
defaultMessage: "Voucher",
})
.toUpperCase()
let price = `${numberOfVouchers} ${voucherMsg}`
if (packagesSum.price) {
price = `${price} + ${packagesSum.price}`
}
return (
<CodeRateCard
key={codeProduct.rate}
bannerText={bannerText}
handleChange={() => handleSelectRate(codeProduct.voucher.rateCode)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
rate={{
label: codeProduct.rateDefinition?.title,
price,
unit: packagesSum.currency ?? "",
}}
rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
function getRateTermDetails(codeProduct: CodeProduct): RateTermDetails {
return codeProduct.rateDefinitionMember
? [
{
title: codeProduct.rateDefinition.title,
terms: codeProduct.rateDefinition.generalTerms,
},
{
title: codeProduct.rateDefinitionMember.title,
terms: codeProduct.rateDefinitionMember.generalTerms,
},
]
: [
{
title: codeProduct.rateDefinition.title,
terms: codeProduct.rateDefinition.generalTerms,
},
]
}
type RateTermDetails = { title: string; terms: string[] }[]

View File

@@ -5,25 +5,37 @@ import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/booki
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
import { sumPackages } from "@/components/HotelReservation/utils"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type {
AvailabilityWithRoomInfo,
Package,
} from "@/contexts/SelectRate/types"
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
interface RedemptionsProps extends SharedRateCardProps {
redemptions: RedemptionProduct[]
type RedemptionsProps = {
redemptions: AvailabilityWithRoomInfo["redemptions"]
roomTypeCode: string
selectedPackages: Package[]
roomIndex: number
}
export default function Redemptions({
handleSelectRate,
redemptions,
roomTypeCode,
roomIndex,
selectedPackages,
}: RedemptionsProps) {
const intl = useIntl()
const rateTitles = useRateTitles()
const { selectedFilter, selectedPackages, selectedRate } = useRoomContext()
const {
actions: { selectRate },
selectedRates,
} = useSelectRateContext()
// TODO: Replace with context value when we have support for dropdown "Show all rates"
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
const selectedRate = selectedRates.forRoom(roomIndex)
if (
selectedFilter === BookingCodeFilterEnum.Discounted ||
@@ -44,22 +56,12 @@ export default function Redemptions({
defaultMessage: "Breakfast excluded",
})
let selectedRateCode = ""
if (selectedRate?.product && "redemption" in selectedRate.product) {
selectedRateCode =
selectedRate.roomTypeCode === roomTypeCode
? selectedRate.product.redemption.rateCode
: ""
}
function handleSelect(rateCode: string) {
const selectedRedemption = redemptions.find(
(r) => r.redemption.rateCode === rateCode
)
if (selectedRedemption) {
handleSelectRate(selectedRedemption)
}
}
const selectedRateCode =
selectedRate &&
"redemption" in selectedRate &&
selectedRate.roomInfo.roomTypeCode === roomTypeCode
? selectedRate.redemption.rateCode
: ""
const rates = redemptions.map((r) => {
let additionalPrice
@@ -107,7 +109,13 @@ export default function Redemptions({
<PointsRateCard
key={firstRedemption.rate}
bannerText={bannerText}
onRateSelect={handleSelect}
onRateSelect={(rateCode: string) => {
selectRate({
roomIndex: roomIndex,
rateCode: rateCode,
roomTypeCode: roomTypeCode,
})
}}
paymentTerm={rateTitles[firstRedemption.rate].paymentTerm}
rates={rates}
rateTitle={rateTitles[firstRedemption.rate].title}

View File

@@ -10,16 +10,15 @@ import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import { isValidClientSession } from "@/utils/clientSession"
import { isSelectedPriceProduct } from "./isSelected"
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type { Package } from "@scandic-hotels/trpc/types/packages"
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
interface Rate {
label: string
@@ -31,25 +30,64 @@ interface Rates {
memberRate?: Rate
rate?: Rate
}
interface RegularProps extends SharedRateCardProps {
regular: PriceProduct[]
type RegularRateProps = {
nights: number
regular: AvailabilityWithRoomInfo["regular"]
roomIndex: number
roomTypeCode: string
selectedPackages: Package[]
}
export default function Regular({
handleSelectRate,
export function RegularRate({
nights,
regular,
roomTypeCode,
}: RegularProps) {
const intl = useIntl()
const rateTitles = useRateTitles()
const { isMainRoom, roomNr, selectedFilter, selectedPackages, selectedRate } =
useRoomContext()
roomIndex,
selectedPackages,
}: RegularRateProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
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
}
@@ -73,139 +111,147 @@ export default function Regular({
defaultMessage: "Approx.",
})
return regular.map((product) => {
const { member, public: standard } = product
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
const isMainRoomLoggedInWithoutMember =
isMainRoomAndLoggedIn && !product.member
const noRateAvailable = !product.member && !product.public
const isMemberRateActive = isMainRoomAndLoggedIn && !!member
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
const rateCode = isMemberRateActive ? member.rateCode : standard?.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) {
const standardPriceUnit = isMemberRateActive
? standard!.localPrice.currency
: `${standard!.localPrice.currency}/${night}`
rates.rate = {
label: standardPriceMsg,
price: standardPricePerNight.totalPrice,
unit: standardPriceUnit,
}
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 isSelected = isSelectedPriceProduct(
product,
selectedRate,
roomTypeCode
)
const rateTermDetails = product.rateDefinitionMember
? [
{
title: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
{
title: memberPriceMsg,
terms: product.rateDefinitionMember.generalTerms,
},
]
: [
{
title: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
]
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 (
<RegularRateCard
{...rates}
<NoRateAvailableCard
key={product.rate}
approximateRate={approximateRate}
handleChange={() => handleSelectRate(product)}
isMemberRateActive={isMemberRateActive}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
noPricesAvailableText={rateTitles.noPriceAvailable}
paymentTerm={rateTitles[product.rate].paymentTerm}
rateTitle={rateTitles[product.rate].title}
value={rateCode}
rateTermDetails={rateTermDetails}
variant="Regular"
/>
)
}
const memberPricePerNight = member
? calculatePricePerNightPriceProduct(
member.localPrice.pricePerNight,
member.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
: undefined
const standardPricePerNight = standard
? calculatePricePerNightPriceProduct(
standard.localPrice.pricePerNight,
standard.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
: undefined
let approximateMemberRatePrice = null
const rates: Rates = {}
if (memberPricePerNight) {
rates.memberRate = {
label: memberPriceMsg,
price: memberPricePerNight.totalPrice,
unit: `${member!.localPrice.currency}/${night}`,
}
if (memberPricePerNight.totalRequestedPrice) {
approximateMemberRatePrice = memberPricePerNight.totalRequestedPrice
}
}
let approximateStandardRatePrice = null
if (standardPricePerNight) {
rates.rate = {
label: standardPriceMsg,
price: standardPricePerNight.totalPrice,
unit: `${standard!.localPrice.currency}/${night}`,
}
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
approximateStandardRatePrice = standardPricePerNight.totalRequestedPrice
}
}
let approximatePrice = ""
if (approximateStandardRatePrice && approximateMemberRatePrice) {
approximatePrice = `${approximateStandardRatePrice}/${approximateMemberRatePrice}`
} else if (approximateStandardRatePrice) {
approximatePrice = approximateStandardRatePrice
} else if (approximateMemberRatePrice) {
approximatePrice = approximateMemberRatePrice
}
const requestedCurrency =
standard?.requestedPrice?.currency || member?.requestedPrice?.currency
const approximateRate =
approximatePrice && requestedCurrency
? {
label: approxMsg,
price: approximatePrice,
unit: requestedCurrency,
}
: undefined
const rateTermDetails = product.rateDefinitionMember
? [
{
title: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
{
title: memberPriceMsg,
terms: product.rateDefinitionMember.generalTerms,
},
]
: [
{
title: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
]
const isSelected = isRateSelected({
roomIndex,
rate: { ...product, type: "regular" },
roomTypeCode,
})
const isMemberRateActive = isUserLoggedIn && isMainRoom && !!member
return (
<RegularRateCard
{...rates}
key={product.rate}
approximateRate={approximateRate}
handleChange={() => {
selectRate({
roomIndex: roomIndex,
rateCode: rateCode,
roomTypeCode: roomTypeCode,
counterRateCode: counterRateCode,
})
}}
isMemberRateActive={isMemberRateActive}
isSelected={isSelected}
name={`rateCode-${roomIndex + 1}-${rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rateTitle={rateTitles[product.rate].title}
value={rateCode}
rateTermDetails={rateTermDetails}
/>
)
}

View File

@@ -1,61 +1,50 @@
"use client"
import { useSession } from "next-auth/react"
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import { dt } from "@scandic-hotels/common/dt"
import { Divider } from "@scandic-hotels/design-system/Divider"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { isValidClientSession } from "@/utils/clientSession"
import BreakfastMessage from "./BreakfastMessage"
import { BreakfastMessage } from "./BreakfastMessage"
import Campaign from "./Campaign"
import Code from "./Code"
import Redemptions from "./Redemptions"
import Regular from "./Regular"
import { RegularRate } from "./Regular"
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
import type { Package } from "@scandic-hotels/trpc/types/packages"
import type { RatesProps } from "@/types/components/hotelReservation/selectRate/rates"
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
export default function Rates({
export interface RatesProps {
roomConfiguration: AvailabilityWithRoomInfo
roomIndex: number
selectedPackages: Package[]
}
export function Rates({
roomConfiguration: {
breakfastIncludedInAllRates,
breakfastIncludedInAllRatesMember,
campaign,
code,
features,
redemptions,
regular,
roomType,
roomTypeCode,
},
selectedPackages,
roomIndex,
}: RatesProps) {
const {
actions: { selectRate },
isFetchingAdditionalRate,
selectedFilter,
} = useRoomContext()
const nights = useRatesStore((state) =>
dt(state.booking.toDate).diff(state.booking.fromDate, "days")
)
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
function handleSelectRate(product: Product) {
selectRate({ features, product, roomType, roomTypeCode }, isUserLoggedIn)
}
bookingCodeFilter,
input: { nights },
} = useSelectRateContext()
const sharedProps = {
handleSelectRate,
nights,
roomTypeCode,
roomIndex,
selectedPackages,
}
const showAllRates = selectedFilter === BookingCodeFilterEnum.All
const showAllRates = bookingCodeFilter === BookingCodeFilterEnum.All
const hasBookingCodeRates = !!(campaign.length || code.length)
const hasRegularRates = !!regular.length
const showDivider = showAllRates && hasBookingCodeRates && hasRegularRates
@@ -66,18 +55,13 @@ export default function Rates({
<Campaign {...sharedProps} campaign={campaign} />
<Redemptions {...sharedProps} redemptions={redemptions} />
{showDivider ? <Divider color="Border/Divider/Subtle" /> : null}
{isFetchingAdditionalRate ? (
<>
<SkeletonShimmer height="100px" />
<SkeletonShimmer height="100px" />
</>
) : null}
<BreakfastMessage
breakfastIncludedMember={breakfastIncludedInAllRatesMember}
breakfastIncludedStandard={breakfastIncludedInAllRates}
hasRegularRates={!!regular.length}
hasRegularRates={hasRegularRates && showAllRates}
roomIndex={roomIndex}
/>
<Regular {...sharedProps} regular={regular} />
<RegularRate {...sharedProps} regular={regular} />
</>
)
}

View File

@@ -1,68 +1,50 @@
"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 { useRatesStore } from "@/stores/select-rate"
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import ToggleSidePeek from "../Details/ToggleSidePeek"
import styles from "./image.module.css"
import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
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 default function RoomImage({
roomPackages,
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 intl = useIntl()
const { selectedPackages } = useRoomContext()
const { roomCategories, hotelId } = useRatesStore((state) => ({
roomCategories: state.roomCategories,
hotelId: state.booking.hotelId,
}))
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
const selectedRoom = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
)
const galleryImages = mapApiImagesToGalleryImages(selectedRoom?.images || [])
const galleryImages = mapApiImagesToGalleryImages(images || [])
return (
<div className={styles.imageContainer}>
<div className={styles.chipContainer}>
{showLowInventory ? (
<span className={styles.chip}>
<Typography variant="Tag/sm">
<p className={styles.inventory}>
{intl.formatMessage(
{
defaultMessage: "{amount, number} left",
},
{ amount: roomsLeft }
)}
</p>
</Typography>
<LowInventoryTag roomsLeft={roomsLeft} />
{selectedPackages.map((pkg) => (
<span className={styles.chip} key={pkg}>
{IconForFeatureCode({ featureCode: pkg, size: 16 })}
</span>
) : null}
{roomPackages
.filter((pkg) =>
selectedPackages.find((spkg) => spkg.code === pkg.code)
)
.map((pkg) => (
<span className={styles.chip} key={pkg.code}>
{IconForFeatureCode({ featureCode: pkg.code, size: 16 })}
</span>
))}
))}
</div>
<ImageGallery
images={galleryImages}
@@ -77,4 +59,30 @@ export default function RoomImage({
</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>
)
}

View File

@@ -1,24 +1,37 @@
"use client"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import Details from "./Details"
import { listItemVariants } from "./listItemVariants"
import Rates from "./Rates"
import { Rates } from "./Rates"
import RoomImage from "./RoomImage"
import RoomNotAvailable from "./RoomNotAvailable"
import styles from "./roomListItem.module.css"
import type { RoomListItemProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
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
}
export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
const { roomPackages } = useRoomContext()
const classNames = listItemVariants({
availability:
roomConfiguration.status === AvailabilityEnum.NotAvailable
room.status === AvailabilityEnum.NotAvailable
? "noAvailability"
: "default",
})
@@ -26,18 +39,24 @@ export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
return (
<li className={classNames}>
<RoomImage
roomPackages={roomPackages}
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
roomsLeft={roomConfiguration.roomsLeft}
roomType={room.roomType}
roomTypeCode={room.roomTypeCode}
roomsLeft={room.roomsLeft}
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
images={room.roomInfo.images ?? []}
hotelId={hotelId}
/>
<Details roomTypeCode={roomConfiguration.roomTypeCode} />
<Details roomInfo={room.roomInfo} />
<div className={styles.container}>
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
{room.status === AvailabilityEnum.NotAvailable ? (
<RoomNotAvailable />
) : (
<Rates roomConfiguration={roomConfiguration} />
<Rates
roomConfiguration={room}
roomIndex={roomIndex}
selectedPackages={selectedPackages}
/>
)}
</div>
</li>

View File

@@ -1,43 +1,52 @@
"use client"
import { useEffect } from "react"
import { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import styles from "./rooms.module.css"
export default function ScrollToList() {
const { isSingleRoomAndHasSelection } = useRatesStore((state) => ({
isSingleRoomAndHasSelection:
state.booking.rooms.length === 1 && !!state.rateSummary.length,
}))
const {
input: { isMultiRoom },
selectedRates,
} = useSelectRateContext()
const selectedRateCode = selectedRates.rates[0]
? `${selectedRates.rates[0].rateDefinition.rateCode}${selectedRates.rates[0].roomInfo.roomTypeCode}`
: null
useEffect(() => {
if (isSingleRoomAndHasSelection) {
// 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",
})
}
})
if (isMultiRoom) {
return
}
}, [isSingleRoomAndHasSelection])
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
}

View File

@@ -1,27 +1,40 @@
"use client"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import RoomListItem from "./RoomListItem"
import { RoomListItem } from "./RoomListItem"
import { RoomsListSkeleton } from "./RoomsListSkeleton"
import ScrollToList from "./ScrollToList"
import styles from "./rooms.module.css"
export default function RoomsList() {
const { isFetchingPackages, rooms } = useRoomContext()
if (isFetchingPackages) {
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}>
{rooms.map((roomConfiguration) => (
<RoomListItem
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
{getAvailabilityForRoom(roomIndex)?.map((room, ix) => {
return (
<RoomListItem
key={room.roomTypeCode + ix}
room={room}
selectedPackages={getPackagesForRoom(roomIndex).selectedPackages}
roomIndex={roomIndex}
hotelId={hotelId}
/>
)
})}
</ul>
</>
)

View File

@@ -1,92 +1,39 @@
"use client"
import { useEffect } from "react"
import { useRatesStore } from "@/stores/select-rate"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import RoomProvider from "@/providers/SelectRate/RoomProvider"
import { trackLowestRoomPrice } from "@/utils/tracking"
import MultiRoomWrapper from "./MultiRoomWrapper"
import { MultiRoomWrapper } from "./MultiRoomWrapper"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import RoomsHeader from "./RoomsHeader"
import { RoomsHeader } from "./RoomsHeader"
import RoomsList from "./RoomsList"
import styles from "./rooms.module.css"
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
export default function Rooms() {
const {
arrivalDate,
bookingRooms,
departureDate,
hotelId,
rooms,
visibleRooms,
} = useRatesStore((state) => ({
arrivalDate: state.booking.fromDate,
bookingRooms: state.booking.rooms,
departureDate: state.booking.toDate,
hotelId: state.booking.hotelId,
rooms: state.rooms,
visibleRooms: state.roomConfigurations,
}))
availability,
input: { isMultiRoom },
} = useSelectRateContext()
useEffect(() => {
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
roomConfiguration.flatMap((room) =>
room.products
.filter(
(product): product is PriceProduct =>
!!(
("public" in product && product.public) ||
("member" in product && product.member)
)
)
.map((product) => ({
currency: (product.public?.localPrice.currency ||
product.member?.localPrice.currency)!,
price: (product.public?.localPrice.pricePerNight ||
product.member?.localPrice.pricePerNight)!,
}))
)
)
// Specific n/a when no prices available in reward night and voucher scenarios
const lowestPrice = pricesWithCurrencies.length
? pricesWithCurrencies
.reduce((minPrice, { price }) => Math.min(minPrice, price), Infinity)
.toString()
: "n/a"
const currency = pricesWithCurrencies.length
? pricesWithCurrencies[0]?.currency
: "n/a"
trackLowestRoomPrice({
hotelId,
arrivalDate,
departureDate,
lowestPrice: lowestPrice,
currency: currency,
})
}, [arrivalDate, departureDate, hotelId, visibleRooms])
if (!availability) {
return null
}
return (
<div className={styles.content}>
{bookingRooms.map((room, idx) => (
<RoomProvider
key={`${room.rateCode}-${room.roomTypeCode}-${idx}`}
idx={idx}
room={rooms[idx]}
>
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
<RoomsHeader />
<NoAvailabilityAlert />
<RoomsList />
{availability.data?.map((_room, idx) => {
return (
<MultiRoomWrapper
key={`${idx}`}
roomIndex={idx}
isMultiRoom={isMultiRoom}
>
<RoomsHeader roomIndex={idx} />
<NoAvailabilityAlert roomIndex={idx} />
<RoomsList roomIndex={idx} />
</MultiRoomWrapper>
</RoomProvider>
))}
)
})}
</div>
)
}

View File

@@ -1,69 +1,37 @@
"use client"
import { notFound, useSearchParams } from "next/navigation"
import { TRPCClientError } from "@trpc/client"
import { useIntl } from "react-intl"
import {
parseSelectRateSearchParams,
searchParamsToRecord,
} from "@scandic-hotels/booking-flow/utils/url"
import { trpc } from "@scandic-hotels/trpc/client"
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/input"
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import RatesProvider from "@/providers/RatesProvider"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import RateSummary from "./RateSummary"
import { RateSummary } from "./RateSummary"
import Rooms from "./Rooms"
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
import styles from "./index.module.css"
import type { AppRouter } from "@scandic-hotels/trpc/routers/appRouter"
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
export function RoomsContainer({
hotelType,
roomCategories,
vat,
}: RoomsContainerProps) {
const lang = useLang()
export function RoomsContainer({}: RoomsContainerProps) {
const intl = useIntl()
const searchParams = useSearchParams()
const booking = parseSelectRateSearchParams(
searchParamsToRecord(searchParams)
)
if (!booking) return notFound()
const bookingInput = selectRateRoomsAvailabilityInputSchema.safeParse({
booking,
lang,
})
const { data, isFetching, isError, error } =
trpc.hotel.availability.selectRate.rooms.useQuery(bookingInput.data!, {
retry(failureCount, error) {
if (error.data?.code === "BAD_REQUEST") {
return false
}
return failureCount <= 3
},
enabled: bookingInput.success,
})
const {
availability: { error, isFetching, isError },
input: { hasError: hasInputError },
} = useSelectRateContext()
if (isFetching) {
return <RoomsContainerSkeleton />
}
if (isError || !bookingInput.success) {
const errorMessage = getErrorMessage(
error?.data?.zodError?.formErrors,
intl
)
if (isError || hasInputError) {
const errorMessage = getErrorMessage(error, intl)
return (
<div className={styles.errorContainer}>
@@ -73,24 +41,21 @@ export function RoomsContainer({
}
return (
<RatesProvider
booking={booking}
hotelType={hotelType}
roomCategories={roomCategories}
roomsAvailability={data}
vat={vat}
>
<>
<Rooms />
<RateSummary />
</RatesProvider>
</>
)
}
function getErrorMessage(
formErrors: string[] | undefined,
intl: ReturnType<typeof useIntl>
) {
const firstError = formErrors?.at(0)
function getErrorMessage(error: unknown, intl: ReturnType<typeof useIntl>) {
if (!isTRPCClientError(error)) {
return intl.formatMessage({
defaultMessage: "Something went wrong",
})
}
const firstError = error.data?.zodError?.formErrors?.at(0)
switch (firstError) {
case "FROMDATE_INVALID":
@@ -107,3 +72,9 @@ function getErrorMessage(
})
}
}
function isTRPCClientError(
cause: unknown
): cause is TRPCClientError<AppRouter> {
return cause instanceof TRPCClientError
}

View File

@@ -1,55 +1,35 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { FamilyAndFriendsCodes } from "@/constants/booking"
import { getHotel } from "@/lib/trpc/memoizedRequests"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { HotelInfoCard } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import FnFNotAllowedAlert from "../FnFNotAllowedAlert/FnFNotAllowedAlert"
import AvailabilityError from "./AvailabilityError"
import Tracking from "./Tracking"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { RouterOutput } from "@scandic-hotels/trpc/client"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
export default async function SelectRatePage({
lang,
booking,
hotelData,
}: {
lang: Lang
hotelData: NonNullable<RouterOutput["hotel"]["get"]>
booking: SelectRateBooking
}) {
const searchDetails = await getHotelSearchDetails(booking)
if (!searchDetails?.hotel) {
return notFound()
}
const hotelData = await getHotel({
hotelId: searchDetails.hotel.id,
isCardOnlyPayment: false,
language: lang,
})
if (!hotelData) {
return notFound()
}
const bookingCode = booking.bookingCode
let isInValidFNF = false
if (
booking.bookingCode &&
FamilyAndFriendsCodes.includes(booking.bookingCode)
) {
if (bookingCode && FamilyAndFriendsCodes.includes(bookingCode)) {
const cookieStore = await cookies()
isInValidFNF = cookieStore.get("sc")?.value !== "1"
}
return (
<>
<HotelInfoCard booking={booking} hotel={hotelData.hotel} />
<HotelInfoCard hotel={hotelData.hotel} booking={booking} />
{isInValidFNF ? (
<FnFNotAllowedAlert />
@@ -62,8 +42,8 @@ export default async function SelectRatePage({
)}
<Tracking
hotelId={searchDetails.hotel.id}
hotelName={searchDetails.hotel.name}
hotelId={hotelData.hotel.id}
hotelName={hotelData.hotel.name}
country={hotelData.hotel.address.country}
city={hotelData.hotel.address.city}
/>

View File

@@ -1,36 +0,0 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { toast } from "@/components/TempDesignSystem/Toasts"
export default function AvailabilityError() {
const intl = useIntl()
const pathname = usePathname()
const searchParams = useSearchParams()
const errorCode = searchParams.get("errorCode")
const hasAvailabilityError =
errorCode === BookingErrorCodeEnum.AvailabilityError
const errorMessage = intl.formatMessage({
defaultMessage:
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
})
useEffect(() => {
if (hasAvailabilityError) {
toast.error(errorMessage)
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`)
}
}, [errorMessage, hasAvailabilityError, pathname, searchParams])
return null
}

View File

@@ -1,75 +0,0 @@
.hotelDescription {
overflow: hidden;
}
.descriptionWrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.collapsed {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.expanded {
display: block;
max-height: none;
}
.expandedContent {
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--Space-x2);
}
.description {
display: flex;
gap: var(--Space-x025);
}
.showMoreButton {
display: flex;
align-items: flex-end;
background-color: transparent;
border-width: 0;
padding: 0;
color: var(--Text-Interactive-Secondary);
cursor: pointer;
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
}
}
.facilities {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
padding-bottom: var(--Space-x2);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.descriptionWrapper {
display: none;
}
}

View File

@@ -1,93 +0,0 @@
"use client"
import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ReadMore from "@/components/HotelReservation/ReadMore"
import Alert from "@/components/TempDesignSystem/Alert"
import styles from "./hotelDescription.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export default function HotelDescription({
description,
hotel,
sortedFacilities,
}: {
description?: string
hotel: Hotel
sortedFacilities: Hotel["detailedFacilities"]
}) {
const intl = useIntl()
const [expanded, setExpanded] = useState(false)
const handleToggle = () => {
setExpanded((prev) => !prev)
}
const textShowMore = intl.formatMessage({
defaultMessage: "Show more",
})
const textShowLess = intl.formatMessage({
defaultMessage: "Show less",
})
return (
<div className={styles.descriptionWrapper}>
<div className={styles.facilityList}>
{sortedFacilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p
className={`${styles.hotelDescription} ${
expanded ? styles.expanded : styles.collapsed
}`}
>
{description}
</p>
</Typography>
<Typography variant="Link/md">
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
{expanded ? textShowLess : textShowMore}
</ButtonRAC>
</Typography>
{expanded && (
<div className={styles.expandedContent}>
<ReadMore
label={intl.formatMessage({
defaultMessage: "See all amenities",
})}
hotelId={hotel.operaId}
showCTA={false}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
{hotel.specialAlerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,150 +0,0 @@
.container {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x3) 0;
}
.hotelName {
color: var(--Text-Heading);
}
.hotelAddress {
color: var(--Text-Tertiary);
}
.wrapper {
display: flex;
margin: 0 auto;
max-width: var(--max-width-page);
position: relative;
flex-direction: column;
gap: var(--Space-x2);
}
.hotelDescription {
display: none;
}
.imageWrapper {
position: relative;
height: 200px;
width: 100%;
border-radius: var(--Corner-radius-md);
}
.hotelContent {
display: flex;
flex-direction: column;
align-items: center;
}
.hotelInformation {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
align-items: center;
text-align: center;
}
.hotelAddressDescription {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
align-items: center;
text-align: center;
}
.hotelAlert {
display: none;
}
.facilities {
display: none;
}
@media screen and (min-width: 768px) {
.container {
padding: var(--Space-x4) 0;
}
}
@media screen and (min-width: 1367px) {
.container {
padding: var(--Space-x4) var(--Space-x5);
}
.hotelDescription {
display: block;
}
.hotelAlert {
display: block;
max-width: var(--max-width-page);
margin: 0 auto;
padding-top: var(--Space-x15);
}
.facilities {
display: flex;
flex-direction: column;
padding: var(--Space-x3) 0 var(--Space-x025);
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
padding-bottom: var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.imageWrapper {
max-width: 360px;
}
.hotelContent {
gap: var(--Space-x6);
align-items: normal;
}
.hotelInformation {
padding-right: var(--Space-x3);
width: min(607px, 100%);
align-items: normal;
text-align: left;
}
.hotelAddressDescription {
align-items: normal;
text-align: left;
}
.wrapper {
gap: var(--Space-x3);
flex-direction: row;
}
.facilities {
padding: var(--Space-x3) var(--Space-x3) var(--Space-x05);
}
.facilityList {
gap: var(--Space-x1);
padding-bottom: var(--Space-x05);
flex-direction: column;
}
.facilityTitle {
display: none;
}
.hotelContent {
flex-direction: row;
align-items: center;
}
.imageWrapper {
align-self: center;
}
}

View File

@@ -1,183 +0,0 @@
import TripAdvisorChip from "@scandic-hotels/booking-flow/components/TripAdvisorChip"
import { dt } from "@scandic-hotels/common/dt"
import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import ReadMore from "../../ReadMore"
import { getHotelAlertsForBookingDates } from "../../utils"
import HotelDescription from "./HotelDescription"
import styles from "./hotelInfoCard.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export type HotelInfoCardProps = {
booking: SelectRateBooking
hotel: Hotel
}
export async function HotelInfoCard({ booking, hotel }: HotelInfoCardProps) {
const intl = await getIntl()
const sortedFacilities = hotel.detailedFacilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5)
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const bookingFromDate = dt(booking.fromDate)
const bookingToDate = dt(booking.toDate)
const specialAlerts = getHotelAlertsForBookingDates(
hotel.specialAlerts,
bookingFromDate,
bookingToDate
)
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<ImageGallery title={hotel.name} images={galleryImages} fill />
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<Typography variant="Title/md">
<h1 className={styles.hotelName}>{hotel.name}</h1>
</Typography>
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.hotelAddress}>
{intl.formatMessage(
{
defaultMessage:
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center",
},
{
address: hotel.address.streetAddress,
city: hotel.address.city,
distanceToCityCenterInKm: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>
{hotel.hotelContent.texts.descriptions?.medium}
</p>
</Typography>
<HotelDescription
key={hotel.operaId}
description={hotel.hotelContent.texts.descriptions?.medium}
hotel={hotel}
sortedFacilities={sortedFacilities}
/>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
{sortedFacilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
<ReadMore
label={intl.formatMessage({
defaultMessage: "See all amenities",
})}
hotelId={hotel.operaId}
showCTA={false}
sidePeekKey={SidePeekEnum.hotelDetails}
/>
</div>
</div>
</section>
{specialAlerts.map((alert) => (
<SpecialAlert key={alert.id} alert={alert} />
))}
</article>
)
}
function SpecialAlert({ alert }: { alert: Hotel["specialAlerts"][number] }) {
return (
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
</div>
)
}
export function HotelInfoCardSkeleton() {
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<SkeletonShimmer height="100%" width="100%" />
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<SkeletonShimmer width="60ch" height="40px" />
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<SkeletonShimmer width="40ch" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
<SkeletonShimmer width="60ch" />
<SkeletonShimmer width="58ch" />
<SkeletonShimmer width="45ch" />
</p>
</Typography>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
<Typography
variant="Body/Paragraph/mdBold"
className={styles.facilityTitle}
>
<SkeletonShimmer width="20ch" />
</Typography>
{[1, 2, 3, 4, 5]?.map((id) => {
return (
<div className={styles.facilitiesItem} key={id}>
<SkeletonShimmer width="10ch" />
</div>
)
})}
</div>
<div className={styles.hotelAlert}>
<SkeletonShimmer width="18ch" />
</div>
</div>
</div>
</section>
</article>
)
}

View File

@@ -1,349 +0,0 @@
"use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import useLang from "@/hooks/useLang"
import { isBookingCodeRate } from "../../utils"
import Room from "../Room"
import styles from "./summaryContent.module.css"
import type { Price } from "@/contexts/SelectRate/getTotalPrice"
export type SelectRateSummaryProps = {
isMember: boolean
bookingCode?: string
toggleSummaryOpen: () => void
}
export default function SummaryContent({
isMember,
toggleSummaryOpen,
}: SelectRateSummaryProps) {
const { selectedRates, input } = useSelectRateContext()
const intl = useIntl()
const lang = useLang()
const rateTitles = useRateTitles()
const nightsLabel = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: input.nights }
)
const memberPrice =
selectedRates.rates.length === 1 &&
selectedRates.rates[0] &&
"member" in selectedRates.rates[0]
? selectedRates.rates[0].member
: null
const containsBookingCodeRate = selectedRates.rates.find(
(r) => r && isBookingCodeRate(r)
)
if (!selectedRates?.totalPrice) {
return null
}
const showDiscounted = containsBookingCodeRate || isMember
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice =
totalRegularPrice > selectedRates?.totalPrice?.local?.price
return (
<section className={styles.summary}>
<header>
<div className={styles.headingWrapper}>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</h3>
</Typography>
<IconButton
className={styles.closeButton}
onPress={toggleSummaryOpen}
theme="Black"
style="Muted"
>
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</IconButton>
</div>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.dates}>
{dt(input.data?.booking.fromDate)
.locale(lang)
.format(longDateFormat[lang])}
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
{dt(input.data?.booking.toDate)
.locale(lang)
.format(longDateFormat[lang])}{" "}
({nightsLabel})
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
</p>
</Typography>
</header>
<Divider color="Border/Divider/Subtle" />
{selectedRates.rates.map((room, idx) => {
if (!room) {
return null
}
return (
<Room
key={idx}
room={mapToRoom({
isMember,
rate: room,
input,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
})}
roomNumber={idx + 1}
roomCount={selectedRates.rates.length}
isMember={isMember}
/>
)
})}
<div>
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
{selectedRates.totalPrice.requested ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.approxPrice}>
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
selectedRates.totalPrice.requested.price,
selectedRates.totalPrice.requested.currency,
selectedRates.totalPrice.requested.additionalPrice,
selectedRates.totalPrice.requested
.additionalPriceCurrency
),
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.prices}>
<Typography variant="Body/Paragraph/mdBold">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
data-testid="total-price"
>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
) : null}
</div>
</div>
<PriceDetailsModal
bookingCode={input.bookingCode}
defaultCurrency={
selectedRates.totalPrice.requested?.currency ??
selectedRates.totalPrice.local.currency
}
rooms={selectedRates.rates
.map((room, idx) => {
if (!room) {
return null
}
const mapped = mapToRoom({
isMember,
rate: room,
input,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
})
function getPrice(
room: NonNullable<(typeof selectedRates.rates)[number]>,
isMember: boolean
) {
switch (room.type) {
case "regular":
return {
regular: isMember
? (room.member?.localPrice ?? room.public?.localPrice)
: room.public?.localPrice,
}
case "campaign":
return {
campaign: isMember
? (room.member ?? room.public)
: room.public,
}
case "redemption":
return {
redemption: room.redemption,
}
case "code": {
if ("corporateCheque" in room) {
return {
corporateCheque: room.corporateCheque,
}
}
if ("voucher" in room) {
return {
voucher: room.voucher,
}
}
if ("public" in room) {
return {
regular: isMember
? (room.member?.localPrice ?? room.public?.localPrice)
: room.public?.localPrice,
}
}
}
default:
throw new Error("Unknown price type")
}
}
const p = getPrice(room!, isMember)
return {
...mapped,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
price: p,
bedType: undefined,
breakfast: undefined,
breakfastIncluded:
room?.rateDefinition.breakfastIncluded ?? false,
rateDefinition: room.rateDefinition,
}
})
.filter((x) => !!x)}
fromDate={input.data?.booking.fromDate ?? ""}
toDate={input.data?.booking.toDate ?? ""}
totalPrice={selectedRates.totalPrice}
vat={selectedRates.vat}
/>
</div>
{!isMember && memberPrice ? (
<SignupPromoDesktop
memberPrice={{
amount: memberPrice.localPrice.pricePerStay,
currency: memberPrice.localPrice.currency,
}}
badgeContent={"✌️"}
/>
) : null}
</section>
)
}
function mapToRoom({
isMember,
rate,
input,
idx,
getPriceForRoom,
rateTitles,
}: {
isMember: boolean
rate: NonNullable<
ReturnType<typeof useSelectRateContext>["selectedRates"]["rates"][number]
>
input: ReturnType<typeof useSelectRateContext>["input"]
idx: number
getPriceForRoom: (roomIndex: number) => Price | null
rateTitles: ReturnType<typeof useRateTitles>
}) {
return {
adults: input.data?.booking.rooms[idx].adults || 0,
childrenInRoom: input.data?.booking.rooms[idx].childrenInRoom,
roomType: rate.roomInfo.roomType,
roomRate: rate,
cancellationText: rateTitles[rate.rate].title,
roomPrice: {
perNight: { local: { price: -1, currency: CurrencyEnum.SEK } },
perStay: getPriceForRoom(idx) ?? {
local: { price: -1, currency: CurrencyEnum.Unknown },
},
},
rateDetails: isMember
? (rate.rateDefinitionMember?.generalTerms ??
rate.rateDefinition.generalTerms)
: rate.rateDefinition.generalTerms,
packages: rate.roomInfo.selectedPackages,
}
}

View File

@@ -1,59 +0,0 @@
.summary {
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Space-x2);
padding: var(--Space-x3);
}
.headingWrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.heading {
color: var(--Text-Default);
}
.closeButton {
margin-top: -10px; /* Compensate for padding of the button */
margin-right: -10px; /* Compensate for padding of the button */
}
.dates {
display: flex;
align-items: center;
gap: var(--Space-x1);
justify-content: flex-start;
color: var(--Text-Accent-Secondary);
}
.entry {
display: flex;
gap: var(--Space-x05);
justify-content: space-between;
margin-bottom: var(--Space-x15);
}
.prices {
justify-items: flex-end;
flex-shrink: 0;
display: grid;
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.approxPrice {
color: var(--Text-Secondary);
}

View File

@@ -1,279 +0,0 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import Modal from "@/components/Modal"
import { isBookingCodeRate } from "../../utils"
import { getMemberPrice } from "../utils"
import styles from "./room.module.css"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type {
RoomPrice,
RoomRate,
} from "@/types/components/hotelReservation/enterDetails/details"
interface RoomProps {
room: {
adults: number
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
roomRate: RoomRate
rateDetails: string[] | undefined
cancellationText: string
packages?: Packages
}
roomNumber: number
roomCount: number
isMember: boolean
}
export default function Room({
room,
roomNumber,
roomCount,
isMember,
}: RoomProps) {
const intl = useIntl()
const adults = room.adults
const childrenInRoom = room.childrenInRoom
const childrenBeds = childrenInRoom?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = getMemberPrice(room.roomRate)
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
const roomPackages = room.packages
return (
<>
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
<div>
{roomCount > 1 ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</p>
</Typography>
) : null}
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdBold">
<p>{room.roomType}</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.additionalInformation}>
<p>{guestsParts.join(", ")}</p>
<p>{room.cancellationText}</p>
</div>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.prices}>
<p
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{showMemberPrice
? formatPrice(
intl,
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</p>
{showDiscounted && room.roomPrice.perStay.local.price ? (
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</s>
) : null}
</div>
</Typography>
</div>
{room.rateDetails?.length ? (
<div className={styles.ctaWrapper}>
<Modal
trigger={
<Button
className={styles.termsButton}
variant="Text"
typography="Body/Supporting text (caption)/smBold"
wrapping={false}
>
{intl.formatMessage({
defaultMessage: "Rate details",
})}
<MaterialIcon
icon="chevron_right"
size={20}
color="CurrentColor"
/>
</Button>
}
title={room.cancellationText}
>
<div className={styles.terms}>
{room.rateDetails.map((info) => (
<Typography key={info} variant="Body/Paragraph/mdRegular">
<p className={styles.termsText}>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</p>
</Typography>
))}
</div>
</Modal>
</div>
) : null}
</div>
{childBedCrib ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib }
)}
</p>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
</span>
</div>
</div>
</Typography>
) : null}
{childBedExtraBed ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed,
}
)}
</p>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
</span>
</div>
</div>
</Typography>
) : null}
{roomPackages?.map((pkg) => (
<Typography key={pkg.code} variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<p>{pkg.description}</p>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(
intl,
pkg.localPrice.price,
pkg.localPrice.currency
)}
</span>
</div>
</div>
</Typography>
))}
</div>
<Divider color="Border/Divider/Subtle" />
</>
)
}

View File

@@ -1,57 +0,0 @@
.room {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
overflow-y: auto;
color: var(--Text-Default);
}
.roomTitle,
.additionalInformation {
color: var(--Text-Secondary);
}
.terms {
margin-top: var(--Space-x3);
margin-bottom: var(--Space-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
margin-bottom: var(--Space-x1);
}
.terms .termsIcon {
margin-right: var(--Space-x1);
}
.entry {
display: flex;
gap: var(--Space-x05);
justify-content: space-between;
}
.prices {
justify-items: flex-end;
flex-shrink: 0;
display: grid;
align-content: start;
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.ctaWrapper {
margin-top: var(--Space-x15);
}

View File

@@ -1,154 +0,0 @@
"use client"
import { cx } from "class-variance-authority"
import { useEffect, useRef, useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import { isBookingCodeRate } from "../utils"
import SummaryContent from "./Content"
import styles from "./mobileSummary.module.css"
export function MobileSummary() {
const intl = useIntl()
const scrollY = useRef(0)
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const isUserLoggedIn = useIsUserLoggedIn()
const { selectedRates } = useSelectRateContext()
function toggleSummaryOpen() {
setIsSummaryOpen(!isSummaryOpen)
}
useEffect(() => {
if (isSummaryOpen) {
scrollY.current = window.scrollY
document.body.style.position = "fixed"
document.body.style.top = `-${scrollY.current}px`
document.body.style.width = "100%"
} else {
document.body.style.position = ""
document.body.style.top = ""
document.body.style.width = ""
window.scrollTo({
top: scrollY.current,
left: 0,
behavior: "instant",
})
}
return () => {
document.body.style.position = ""
document.body.style.top = ""
document.body.style.width = ""
}
}, [isSummaryOpen])
const containsBookingCodeRate = selectedRates.rates.find(
(r) => r && isBookingCodeRate(r)
)
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
if (!selectedRates.totalPrice) {
return null
}
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice =
totalRegularPrice > selectedRates.totalPrice.local?.price
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<SummaryContent
isMember={isUserLoggedIn}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
</div>
<div className={styles.bottomSheet}>
<ButtonRAC
data-open={isSummaryOpen}
onPress={toggleSummaryOpen}
className={styles.priceDetailsButton}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.priceLabel}>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</span>
</Typography>
<Typography variant="Title/Subtitle/lg">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.seeDetails}>
<span>
{intl.formatMessage({
defaultMessage: "See details",
})}
</span>
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={20}
/>
</span>
</Typography>
</ButtonRAC>
<Button
variant="Primary"
color="Primary"
size="Large"
type="submit"
typography="Body/Paragraph/mdBold"
isDisabled={selectedRates.state !== "ALL_SELECTED"}
>
{intl.formatMessage({
defaultMessage: "Continue",
})}
</Button>
</div>
</div>
)
}

View File

@@ -1,115 +0,0 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { Price } from "@/types/components/hotelReservation/price"
import type {
Rate,
Room,
} from "@/types/components/hotelReservation/selectRate/selectRate"
export function mapRate(
room: Rate,
index: number,
bookingRooms: Room[],
packages: NonNullable<Packages>
) {
const rate = {
adults: bookingRooms[index].adults,
cancellationText: room.product.rateDefinition?.cancellationText ?? "",
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
rateDetails: room.product.rateDefinition?.generalTerms,
roomPrice: {
currency: CurrencyEnum.Unknown,
perNight: <Price>{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
},
requested: undefined,
},
perStay: <Price>{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
},
requested: undefined,
},
},
roomRate: room.product,
roomType: room.roomType,
packages,
}
if ("corporateCheque" in room.product) {
rate.roomPrice.currency = CurrencyEnum.CC
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.CC,
price: room.product.corporateCheque.localPrice.numberOfCheques,
additionalPrice:
room.product.corporateCheque.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.corporateCheque.localPrice.currency ??
CurrencyEnum.Unknown,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.CC,
price: room.product.corporateCheque.localPrice.numberOfCheques,
additionalPrice:
room.product.corporateCheque.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.corporateCheque.localPrice.currency ??
CurrencyEnum.Unknown,
}
} else if ("redemption" in room.product) {
rate.roomPrice.currency = CurrencyEnum.POINTS
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.POINTS,
price: room.product.redemption.localPrice.pointsPerNight,
additionalPrice:
room.product.redemption.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.POINTS,
price: room.product.redemption.localPrice.pointsPerStay,
additionalPrice:
room.product.redemption.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
}
} else if ("voucher" in room.product) {
rate.roomPrice.currency = CurrencyEnum.Voucher
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.Voucher,
price: room.product.voucher.numberOfVouchers,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.Voucher,
price: room.product.voucher.numberOfVouchers,
}
} else {
const currency =
room.product.public?.localPrice.currency ||
room.product.member?.localPrice.currency ||
CurrencyEnum.Unknown
rate.roomPrice.currency = currency
rate.roomPrice.perNight.local = {
currency,
price:
room.product.public?.localPrice.pricePerNight ||
room.product.member?.localPrice.pricePerNight ||
0,
}
rate.roomPrice.perStay.local = {
currency,
price:
room.product.public?.localPrice.pricePerStay ||
room.product.member?.localPrice.pricePerStay ||
0,
}
}
return rate
}

View File

@@ -1,65 +0,0 @@
import type {
Rate,
Room as SelectRateRoom,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Room } from "@/components/HotelReservation/PriceDetailsModal/PriceDetailsTable"
export function mapToPrice(
rooms: (Rate | null)[],
bookingRooms: SelectRateRoom[],
isUserLoggedIn: boolean
) {
return rooms
.map((room, idx) => {
if (!room) {
return null
}
let price = null
if ("corporateCheque" in room.product) {
price = {
corporateCheque: room.product.corporateCheque.localPrice,
}
} else if ("redemption" in room.product) {
price = {
redemption: room.product.redemption.localPrice,
}
} else if ("voucher" in room.product) {
price = {
voucher: room.product.voucher,
}
} else {
const isMainRoom = idx === 0
const memberRate = room.product.member
const onlyMemberRate = !room.product.public && memberRate
if ((isUserLoggedIn && isMainRoom && memberRate) || onlyMemberRate) {
price = {
regular: {
...memberRate.localPrice,
regularPricePerStay:
room.product.public?.localPrice.pricePerStay ||
memberRate.localPrice.pricePerStay,
},
}
} else if (room.product.public) {
price = {
regular: room.product.public.localPrice,
}
}
}
const bookingRoom = bookingRooms[idx]
return {
adults: bookingRoom.adults,
bedType: undefined,
breakfast: undefined,
breakfastIncluded: room.product.rateDefinition.breakfastIncluded,
childrenInRoom: bookingRoom.childrenInRoom,
packages: room.packages,
price,
roomType: room.roomType,
rateDefinition: room.product.rateDefinition,
}
})
.filter((r) => !!(r && r.price)) as Room[]
}

View File

@@ -1,108 +0,0 @@
.wrapper {
position: relative;
display: grid;
grid-template-rows: 0fr auto;
transition: all 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
align-content: end;
z-index: var(--default-modal-z-index);
&[data-open="true"] {
grid-template-rows: 1fr auto;
.bottomSheet {
grid-template-columns: 0fr auto;
}
.priceDetailsButton {
opacity: 0;
height: 0;
}
}
&[data-open="false"] .priceDetailsButton {
opacity: 1;
height: auto;
}
}
.signupPromoWrapper {
position: relative;
z-index: var(--default-modal-z-index);
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--Overlay-40);
z-index: var(--default-modal-overlay-z-index);
}
.bottomSheet {
display: grid;
grid-template-columns: 1fr 1fr;
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
align-items: flex-start;
transition: all 0.5s ease-in-out;
width: 100vw;
}
.priceDetailsButton {
border-width: 0;
background-color: transparent;
text-align: start;
cursor: pointer;
padding: 0;
display: grid;
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.content {
max-height: 50dvh;
overflow-y: auto;
}
.summaryAccordion {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
.priceLabel {
color: var(--Text-Default);
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.seeDetails {
margin-top: var(--Space-x15);
display: flex;
gap: var(--Space-x1);
align-items: center;
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
}
@media screen and (min-width: 768px) {
.bottomSheet {
padding: var(--Space-x2) 0 var(--Space-x7);
}
}

View File

@@ -1,106 +0,0 @@
.summary {
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
.header button {
display: grid;
grid-template-areas: "title button" "date date";
grid-template-columns: 1fr auto;
align-items: center;
width: 100%;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
}
.title {
grid-area: title;
}
.chevronIcon {
grid-area: button;
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
display: none;
}
.modalContent {
width: 560px;
}
.terms {
margin-top: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
margin-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
margin-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.summary .header .chevronButton {
display: none;
}
}

View File

@@ -1,13 +0,0 @@
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
export function getMemberPrice(roomRate: RoomRate) {
if ("member" in roomRate && roomRate.member) {
return {
amount: roomRate.member.localPrice.pricePerStay,
currency: roomRate.member.localPrice.currency,
pricePerNight: roomRate.member.localPrice.pricePerNight,
}
}
return null
}

View File

@@ -1,83 +0,0 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useTransition } from "react"
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { DesktopSummary } from "./DesktopSummary"
import { MobileSummary } from "./MobileSummary"
import styles from "./rateSummary.module.css"
export function RateSummary() {
return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<ErrorBoundary fallback={<div>Unable to render summary</div>}>
<InnerRateSummary />
</ErrorBoundary>
)
}
function InnerRateSummary() {
const { selectedRates, input } = useSelectRateContext()
const [isSubmitting, setIsSubmitting] = useState(false)
const router = useRouter()
const params = useSearchParams()
const [_, startTransition] = useTransition()
if (selectedRates.state === "NONE_SELECTED") {
return null
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSubmitting(true)
startTransition(() => {
router.push(`details?${params}`)
})
}
const totalPriceToShow = selectedRates.totalPrice
if (
!totalPriceToShow ||
!selectedRates.rates.some((room) => room?.isSelected ?? false)
) {
return null
}
// attribute data-footer-spacing used to add spacing
// beneath footer to be able to show entire footer upon
// scrolling down to the bottom of the page
return (
<form
data-footer-spacing
action={`details?${params}`}
method="GET"
onSubmit={handleSubmit}
>
<div className={styles.summary}>
<div className={styles.content}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<ErrorBoundary fallback={<div>Unable to render desktop summary</div>}>
<DesktopSummary
isSubmitting={isSubmitting}
input={input}
selectedRates={selectedRates}
bookingCode={input.data?.booking.bookingCode || ""}
/>
</ErrorBoundary>
</div>
<div className={styles.mobileSummary}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
<MobileSummary />
</ErrorBoundary>
</div>
</div>
</form>
)
}

View File

@@ -1,122 +0,0 @@
@keyframes slideUp {
0% {
bottom: -100%;
}
100% {
bottom: 0%;
}
}
.summary {
align-items: center;
animation: slideUp 300ms ease forwards;
background-color: var(--Base-Surface-Primary-light-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
bottom: -100%;
left: 0;
position: fixed;
right: 0;
z-index: 99;
}
.content {
display: none;
}
.summaryPriceContainer {
display: flex;
flex-direction: row;
gap: var(--Spacing-x4);
padding-top: var(--Spacing-x2);
width: 100%;
}
.promoContainer {
display: none;
max-width: 264px;
}
.summaryPrice {
align-self: center;
display: flex;
width: 100%;
gap: var(--Spacing-x4);
}
.petInfo {
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-left: var(--Spacing-x2);
display: none;
}
.summaryText {
display: none;
}
.summaryPriceTextDesktop {
align-self: center;
display: none;
}
.continueButton {
margin-left: auto;
height: fit-content;
width: 100%;
min-width: 140px;
}
.summaryPriceTextMobile {
white-space: nowrap;
}
.mobileSummary {
display: block;
}
@media (min-width: 1367px) {
.summary {
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
}
.content {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0 auto;
max-width: var(--max-width-page);
width: 100%;
}
.petInfo,
.promoContainer,
.summaryPriceTextDesktop {
display: block;
}
.summaryText {
display: flex;
gap: var(--Spacing-x2);
}
.summaryPriceTextMobile {
display: none;
}
.summaryPrice,
.continueButton {
width: auto;
}
.summaryPriceContainer {
width: auto;
padding: 0;
align-items: center;
}
.mobileSummary {
display: none;
}
}

View File

@@ -1,265 +0,0 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
import { sumPackages } from "@/components/HotelReservation/utils"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type {
Product,
RedemptionProduct,
} from "@scandic-hotels/trpc/types/roomAvailability"
import type { IntlShape } from "react-intl"
import type { Price } from "@/types/components/hotelReservation/price"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
export function calculateTotalPrice(
selectedRateSummary: Rate[],
isUserLoggedIn: boolean
) {
return selectedRateSummary.reduce<Price>(
(total, room, idx) => {
if (!("member" in room.product) || !("public" in room.product)) {
return total
}
const roomNr = idx + 1
const isMainRoom = roomNr === 1
let rate
if (isUserLoggedIn && isMainRoom && room.product.member) {
rate = room.product.member
} else if (room.product.public) {
rate = room.product.public
}
if (!rate) {
return total
}
const packagesPrice = room.packages.reduce(
(total, pkg) => {
total.local = total.local + pkg.localPrice.totalPrice
if (pkg.requestedPrice.totalPrice) {
total.requested = total.requested + pkg.requestedPrice.totalPrice
}
return total
},
{ local: 0, requested: 0 }
)
total.local.currency = rate.localPrice.currency
total.local.price =
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice =
(total.local.regularPrice || 0) +
rate.localPrice.regularPricePerStay +
packagesPrice.local
}
if (rate.requestedPrice) {
if (!total.requested) {
total.requested = {
currency: rate.requestedPrice.currency,
price: 0,
}
}
if (!total.requested.currency) {
total.requested.currency = rate.requestedPrice.currency
}
total.requested.price =
total.requested.price +
rate.requestedPrice.pricePerStay +
packagesPrice.requested
if (rate.requestedPrice.regularPricePerStay) {
total.requested.regularPrice =
(total.requested.regularPrice || 0) +
rate.requestedPrice.regularPricePerStay +
packagesPrice.requested
}
}
return total
},
{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
regularPrice: undefined,
},
requested: undefined,
}
)
}
export function calculateRedemptionTotalPrice(
redemption: RedemptionProduct["redemption"],
packages: Packages | null
) {
const pkgsSum = sumPackages(packages)
let additionalPrice
if (redemption.localPrice.additionalPricePerStay) {
additionalPrice =
redemption.localPrice.additionalPricePerStay + pkgsSum.price
} else if (pkgsSum.price) {
additionalPrice = pkgsSum.price
}
let additionalPriceCurrency
if (redemption.localPrice.currency) {
additionalPriceCurrency = redemption.localPrice.currency
} else if (pkgsSum.currency) {
additionalPriceCurrency = pkgsSum.currency
}
return {
local: {
additionalPrice,
additionalPriceCurrency,
currency: CurrencyEnum.POINTS,
price: redemption.localPrice.pointsPerStay,
},
}
}
export function calculateVoucherPrice(selectedRateSummary: Rate[]) {
return selectedRateSummary.reduce<Price>(
(total, room) => {
if (!("voucher" in room.product)) {
return total
}
const rate = room.product.voucher
total.local.price = total.local.price + rate.numberOfVouchers
const pkgsSum = sumPackages(room.packages)
if (pkgsSum.price && pkgsSum.currency) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) + pkgsSum.price
total.local.additionalPriceCurrency = pkgsSum.currency
}
return total
},
{
local: {
currency: CurrencyEnum.Voucher,
price: 0,
},
requested: undefined,
}
)
}
export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
return selectedRateSummary.reduce<Price>(
(total, room) => {
if (!("corporateCheque" in room.product)) {
return total
}
const rate = room.product.corporateCheque
const pkgsSum = sumPackages(room.packages)
total.local.price = total.local.price + rate.localPrice.numberOfCheques
if (rate.localPrice.additionalPricePerStay) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) +
rate.localPrice.additionalPricePerStay +
pkgsSum.price
} else if (pkgsSum.price) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) + pkgsSum.price
}
if (rate.localPrice.currency) {
total.local.additionalPriceCurrency = rate.localPrice.currency
}
if (rate.requestedPrice) {
if (!total.requested) {
total.requested = {
currency: CurrencyEnum.CC,
price: 0,
}
}
total.requested.price =
total.requested.price + rate.requestedPrice.numberOfCheques
if (rate.requestedPrice.additionalPricePerStay) {
total.requested.additionalPrice =
(total.requested.additionalPrice || 0) +
rate.requestedPrice.additionalPricePerStay
}
if (rate.requestedPrice.currency) {
total.requested.additionalPriceCurrency = rate.requestedPrice.currency
}
}
return total
},
{
local: {
currency: CurrencyEnum.CC,
price: 0,
},
requested: undefined,
}
)
}
export function getTotalPrice(
mainRoomProduct: Rate | null,
rateSummary: Array<Rate | null>,
isUserLoggedIn: boolean,
intl: IntlShape
): Price | null {
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
if (summaryArray.some((rate) => "corporateCheque" in rate.product)) {
return calculateCorporateChequePrice(summaryArray)
}
if (!mainRoomProduct) {
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}
const { packages, product } = mainRoomProduct
// In case of reward night (redemption) or voucher only single room booking is supported by business rules
if ("redemption" in product) {
return calculateRedemptionTotalPrice(product.redemption, packages)
}
if ("voucher" in product) {
const voucherPrice = calculateVoucherPrice(summaryArray)
voucherPrice.local.currency = intl.formatMessage({
defaultMessage: "Voucher",
}) as CurrencyEnum
return voucherPrice
}
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}
export function isBookingCodeRate(product: Product | undefined | null) {
if (!product) return false
if (
"corporateCheque" in product ||
"redemption" in product ||
"voucher" in product
) {
return true
} else {
if (product.public) {
return product.public.rateType !== RateTypeEnum.Regular
}
if (product.member) {
return product.member.rateType !== RateTypeEnum.Regular
}
return false
}
}

View File

@@ -1,195 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { logger } from "@scandic-hotels/common/logger"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Image from "@scandic-hotels/design-system/Image"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
import Chip from "@/components/TempDesignSystem/Chip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import styles from "./selectedRoomPanel.module.css"
export function SelectedRoomPanel({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const isMainRoom = roomIndex === 0
const roomNr = roomIndex + 1
const {
selectedRates,
actions: { setActiveRoom },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const images = selectedRate?.roomInfo?.roomInfo?.images
const rateTitle = useRateTitle(selectedRate?.rate)
const selectedProductTitle = useSelectedProductTitle({ roomIndex })
if (!selectedRate) {
return null
}
if (!selectedProductTitle) {
logger.error("Selected product is unknown")
return null
}
const showModifyButton =
isMainRoom ||
(!isMainRoom && selectedRates.rates.slice(0, roomNr).every((room) => room))
return (
<div className={styles.selectedRoomPanel}>
<div className={styles.content}>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNr }
)}
</Caption>
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{selectedRate.roomInfo.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">{rateTitle}</Body>
<Body color="uiTextHighContrast">{selectedProductTitle}</Body>
</div>
<div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny ? (
<Image
alt={
selectedRate.roomInfo.roomType ??
images[0].metaData?.altText ??
""
}
className={styles.img}
height={300}
src={images[0].imageSizes.tiny}
width={600}
/>
) : null}
{showModifyButton && (
<div className={styles.modifyButtonContainer}>
<Button clean onClick={() => setActiveRoom(roomIndex)}>
<Chip size="small" variant="uiTextHighContrast">
<MaterialIcon
size={16}
color="Icon/Inverted"
icon="edit_square"
/>
{intl.formatMessage({
defaultMessage: "Change",
})}
</Chip>
</Button>
</div>
)}
</div>
</div>
)
}
function useSelectedProductTitle({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const isUserLoggedIn = useIsUserLoggedIn()
const {
selectedRates,
input: { nights },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const night = intl.formatMessage({
defaultMessage: "night",
})
const isMainRoom = roomIndex === 0
if (!selectedRate) {
return null
}
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
(pkg) => pkg.localPrice.currency
)
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const selectedPackagesPricePerNight = Math.ceil(
selectedPackagesPrice / nights
)
if (
isUserLoggedIn &&
isMainRoom &&
"member" in selectedRate &&
selectedRate.member
) {
const { localPrice } = selectedRate.member
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("public" in selectedRate && selectedRate.public) {
const { localPrice } = selectedRate.public
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("corporateCheque" in selectedRate) {
const { localPrice } = selectedRate.corporateCheque
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
localPrice.currency
) {
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
return `${mainProductTitle} + ${packagesText}`
}
}
if ("voucher" in selectedRate) {
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
if (selectedPackagesPrice && selectedPackagesCurrency) {
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
return `${mainProductText} + ${packagesText}`
}
}
}
function useRateTitle(rate: RateEnum | undefined) {
const intl = useIntl()
const freeCancelation = intl.formatMessage({
defaultMessage: "Free cancellation",
})
const nonRefundable = intl.formatMessage({
defaultMessage: "Non-refundable",
})
const freeBooking = intl.formatMessage({
defaultMessage: "Free rebooking",
})
const payLater = intl.formatMessage({
defaultMessage: "Pay later",
})
const payNow = intl.formatMessage({
defaultMessage: "Pay now",
})
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}

View File

@@ -1,53 +0,0 @@
.selectedRoomPanel {
display: grid;
grid-template-areas: "content image";
grid-template-columns: 1fr 190px;
position: relative;
}
.content {
grid-area: content;
}
.imageContainer {
border-radius: var(--Corner-radius-sm);
display: flex;
grid-area: image;
}
.img {
border-radius: var(--Corner-radius-sm);
height: auto;
max-height: 105px;
object-fit: fill;
width: 100%;
}
.modifyButtonContainer {
bottom: var(--Spacing-x1);
position: absolute;
right: var(--Spacing-x1);
}
div.selectedRoomPanel p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media screen and (max-width: 767px) {
.selectedRoomPanel {
gap: var(--Spacing-x1);
grid-template-areas: "image" "content";
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.img {
max-height: 300px;
}
}
@media screen and (max-width: 500px) {
.img {
max-height: 190px;
}
}

View File

@@ -1,137 +0,0 @@
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
}

View File

@@ -1,55 +0,0 @@
.roomContainer {
background: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
padding: var(--Spacing-x3);
}
.header {
align-items: center;
display: flex;
justify-content: space-between;
}
.roomPanel,
.roomSelectionPanel {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
height: 0;
transition:
opacity 0.3s ease,
grid-template-rows 0.3s ease;
transform-origin: bottom;
}
.roomPanel > * {
overflow: hidden;
}
.roomSelectionPanel {
gap: var(--Spacing-x2);
}
.roomSelectionPanelContainer.active .roomSelectionPanel,
.roomSelectionPanelContainer.selected .roomPanel {
grid-template-rows: 1fr;
height: auto;
opacity: 1;
}
.roomSelectionPanelContainer.active .roomPanel {
padding-top: var(--Spacing-x1);
}
.roomSelectionPanelContainer.selected .roomSelectionPanel {
display: none;
}
@media (max-width: 767px) {
.roomContainer {
padding: var(--Spacing-x2);
}
}

View File

@@ -1,21 +0,0 @@
import { cva } from "class-variance-authority"
import styles from "./multiRoomWrapper.module.css"
export const roomSelectionPanelVariants = cva(
styles.roomSelectionPanelContainer,
{
variants: {
active: {
true: styles.active,
},
selected: {
true: styles.selected,
},
},
defaultVariants: {
active: false,
selected: false,
},
}
)

View File

@@ -1,5 +0,0 @@
.hotelAlert {
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
width: 100%;
}

View File

@@ -1,118 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
import Alert from "@/components/TempDesignSystem/Alert"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useLang from "@/hooks/useLang"
import styles from "./alert.module.css"
export default function NoAvailabilityAlert({
roomIndex,
}: {
roomIndex: number
}) {
const lang = useLang()
const intl = useIntl()
const { availability, input } = useSelectRateContext()
if (availability.isFetching || !availability.data) {
return null
}
const indexed = availability.data[roomIndex]
const hasAvailabilityError = "error" in indexed
if (hasAvailabilityError) {
return null
}
const noAvailableRooms = hasAvailableRoomsForRoom(indexed.roomConfigurations)
const alertLink =
roomIndex !== -1 &&
(input.data?.booking.rooms.at(roomIndex)?.packages ?? []).length === 0
? {
title: intl.formatMessage({
defaultMessage: "See alternative hotels",
}),
url: `${alternativeHotels(lang)}`,
keepSearchParams: true,
}
: null
if (noAvailableRooms) {
const text = intl.formatMessage({
defaultMessage: "There are no rooms available that match your request.",
})
return (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={text}
link={alertLink}
/>
</div>
)
}
const isPublicPromotionWithCode = indexed.roomConfigurations.some((room) => {
const filteredCampaigns = room.campaign.filter(Boolean)
return filteredCampaigns.length
? filteredCampaigns.every(
(product) => !!product.rateDefinition?.isCampaignRate
)
: false
})
const noAvailableBookingCodeRooms =
!isPublicPromotionWithCode &&
indexed.roomConfigurations.every(
(room) =>
room.status === AvailabilityEnum.NotAvailable || !room.code.length
)
if (input.bookingCode && noAvailableBookingCodeRooms) {
const bookingCodeText = intl.formatMessage(
{
defaultMessage:
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
},
{ bookingCode: input.bookingCode }
)
return (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={bookingCodeText}
link={alertLink}
/>
</div>
)
}
return null
}
function hasAvailableRoomsForRoom(
roomConfigurations: Extract<
NonNullable<
ReturnType<typeof useSelectRateContext>["availability"]["data"]
>[number],
{ roomConfigurations: unknown }
>["roomConfigurations"]
) {
return roomConfigurations.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
}

View File

@@ -1,34 +0,0 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import BookingCodeChip from "@/components/BookingCodeChip"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
export function RemoveBookingCodeButton() {
const {
input: { bookingCode },
} = useSelectRateContext()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
if (!bookingCode) {
return null
}
return (
<BookingCodeChip
bookingCode={bookingCode}
filledIcon
withCloseButton={true}
withText={false}
onClose={() => {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete("bookingCode")
const url = `${pathname}?${newSearchParams.toString()}`
router.replace(url)
}}
/>
)
}

View File

@@ -1,41 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./petRoom.module.css"
export default function PetRoomMessage({
priceData,
}: {
priceData?: { price: number; currency: string }
}) {
const intl = useIntl()
if (!priceData) {
return null
}
return (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.additionalInformation}>
{intl.formatMessage(
{
defaultMessage:
"Pet-friendly rooms include a charge of approx. <b>{price}/stay</b>",
},
{
b: (str) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.additionalInformationPrice}>{str}</span>
</Typography>
),
price: formatPrice(intl, priceData.price, priceData.currency),
}
)}
</p>
</Typography>
)
}

View File

@@ -1,8 +0,0 @@
.additionalInformation {
color: var(--Text-Tertiary);
padding: var(--Space-x1) var(--Space-x15);
}
.additionalInformationPrice {
color: var(--Text-Default);
}

View File

@@ -1,76 +0,0 @@
.checkboxGroup {
display: grid;
gap: var(--Space-x15);
}
.checkboxWrapper {
display: grid;
gap: var(--Space-x05);
}
.checkboxField {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--Space-x15);
padding: var(--Space-x1) var(--Space-x15);
cursor: pointer;
border-radius: var(--Corner-radius-md);
transition: background-color 0.3s;
color: var(--Text-Default);
&[data-disabled] {
cursor: unset;
.checkbox {
border-color: var(--Border-Interactive-Disabled);
background-color: var(--Surface-UI-Fill-Disabled);
}
.text {
color: var(--Base-Text-Disabled);
}
}
&:hover:not([data-disabled]) {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
&[data-focus-visible] .checkbox {
/* Used this value as it makes sense from a token name perspective and has a good contrast, but we need to decide for a default ui state */
outline: 2px solid var(--Border-Interactive-Focus);
outline-offset: 2px;
}
&[data-selected] .checkbox {
border-color: var(--Surface-UI-Fill-Active);
background-color: var(--Surface-UI-Fill-Active);
}
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 1px solid var(--Border-Interactive-Default);
border-radius: var(--Corner-radius-sm);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--Surface-UI-Fill-Default);
}
.text {
color: var(--Text-Default);
}
@media screen and (max-width: 767px) {
.checkboxField:hover:not([data-disabled]) {
background-color: transparent;
}
.checkboxField[data-selected] {
background-color: transparent;
}
}

View File

@@ -1,101 +0,0 @@
"use client"
import { Checkbox, CheckboxGroup } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { usePackageLabels } from "../../usePackageLabels"
import { getIconNameByPackageCode } from "../../utils"
import styles from "./checkbox.module.css"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
import type { ReactNode } from "react"
import type { FormValues } from "../formValues"
export function PackageCheckboxes({
availablePackages,
}: {
availablePackages: {
code: RoomPackageCodeEnum
message?: ReactNode
}[]
}) {
const { control } = useFormContext<FormValues>()
const packageLabels = usePackageLabels()
return (
<Controller
control={control}
name="selectedPackages"
render={({ field }) => {
const allergyRoomSelected = includesAllergyRoom(field.value)
const petRoomSelected = includesPetRoom(field.value)
return (
<CheckboxGroup {...field} className={styles.checkboxGroup}>
{availablePackages?.map((option) => {
const isAllergyRoom = checkIsAllergyRoom(option.code)
const isPetRoom = checkIsPetRoom(option.code)
const isDisabled =
(isPetRoom && allergyRoomSelected) ||
(isAllergyRoom && petRoomSelected)
const isSelected = field.value.includes(option.code)
const iconName = getIconNameByPackageCode(option.code)
return (
<div key={option.code} className={styles.checkboxWrapper}>
<Checkbox
key={option.code}
className={styles.checkboxField}
isDisabled={isDisabled}
value={option.code}
>
<span className={styles.checkbox}>
{isSelected ? (
<MaterialIcon icon="check" color="Icon/Inverted" />
) : null}
</span>
<Typography
className={styles.text}
variant="Body/Paragraph/mdRegular"
>
<span>{packageLabels[option.code]}</span>
</Typography>
{iconName ? (
<MaterialIcon icon={iconName} color="Icon/Default" />
) : null}
</Checkbox>
{option.message}
</div>
)
})}
</CheckboxGroup>
)
}}
/>
)
}
export function includesAllergyRoom(codes: PackageEnum[]) {
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
}
export function includesPetRoom(codes: PackageEnum[]) {
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
}
export function checkIsAllergyRoom(
code: PackageEnum
): code is RoomPackageCodeEnum.ALLERGY_ROOM {
return code === RoomPackageCodeEnum.ALLERGY_ROOM
}
export function checkIsPetRoom(
code: PackageEnum
): code is RoomPackageCodeEnum.PET_ROOM {
return code === RoomPackageCodeEnum.PET_ROOM
}

View File

@@ -1,30 +0,0 @@
.footer {
padding: 0 var(--Space-x15);
}
.buttonContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.divider {
margin: var(--Space-x15) 0;
}
@media screen and (max-width: 767px) {
.divider {
display: none;
}
.footer {
margin-top: var(--Space-x5);
}
}
@media screen and (min-width: 768px) {
.buttonContainer {
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
}

View File

@@ -1,5 +0,0 @@
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
export type FormValues = {
selectedPackages: PackageEnum[]
}

View File

@@ -1,79 +0,0 @@
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PackageCheckboxes } from "./Checkboxes"
import styles from "./form.module.css"
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
import type { ReactNode } from "react"
import type { FormValues } from "./formValues"
export function RoomPackagesForm({
close,
selectedPackages,
onSelectPackages,
availablePackages,
}: {
close: () => void
availablePackages: {
code: RoomPackageCodeEnum
message: ReactNode
}[]
selectedPackages: PackageEnum[]
onSelectPackages: (packages: PackageEnum[]) => void
}) {
const intl = useIntl()
const methods = useForm<FormValues>({
values: {
selectedPackages: selectedPackages,
},
})
function clearSelectedPackages() {
onSelectPackages([])
close()
}
function onSubmit(data: FormValues) {
onSelectPackages(data.selectedPackages)
close()
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PackageCheckboxes availablePackages={availablePackages} />
<div className={styles.footer}>
<Divider color="Border/Divider/Subtle" className={styles.divider} />
<div className={styles.buttonContainer}>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button variant="Tertiary" size="Small" type="submit">
{intl.formatMessage({ defaultMessage: "Apply" })}
</Button>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<Button
onPress={clearSelectedPackages}
size="Small"
variant="Text"
>
{intl.formatMessage({
defaultMessage: "Clear",
})}
</Button>
</Typography>
</div>
</div>
</form>
</FormProvider>
)
}

View File

@@ -1,76 +0,0 @@
import { type ReactNode, useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RoomPackagesForm } from "./Form"
import styles from "./roomPackageFilter.module.css"
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
export function RoomPackageFilterModal({
selectedPackages,
onSelectPackages,
availablePackages,
}: {
onSelectPackages: (packages: PackageEnum[]) => void
selectedPackages: PackageEnum[]
availablePackages: {
code: RoomPackageCodeEnum
message: ReactNode
}[]
}) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ defaultMessage: "Special needs" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<ModalOverlay className={styles.modalOverlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.modalDialog}>
<div className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({ defaultMessage: "Special needs" })}
</h3>
</Typography>
<IconButton
theme="Black"
style="Muted"
onPress={() => setIsOpen(false)}
>
<MaterialIcon icon="close" size={24} color="CurrentColor" />
</IconButton>
</div>
<RoomPackagesForm
close={() => setIsOpen(false)}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
onSelectPackages={onSelectPackages}
/>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}

View File

@@ -1,53 +0,0 @@
import { type ReactNode, useState } from "react"
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
import { useIntl } from "react-intl"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { RoomPackagesForm } from "./Form"
import styles from "./roomPackageFilter.module.css"
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
export function RoomPackageFilterPopover({
selectedPackages,
onSelectPackages,
availablePackages,
}: {
onSelectPackages: (packages: PackageEnum[]) => void
selectedPackages: PackageEnum[]
availablePackages: {
code: RoomPackageCodeEnum
message: ReactNode
}[]
}) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined">
{intl.formatMessage({ defaultMessage: "Special needs" })}
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</ChipButton>
<Popover placement="bottom end" className={styles.popover}>
<Dialog>
<RoomPackagesForm
close={() => setIsOpen(false)}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
onSelectPackages={onSelectPackages}
/>
</Dialog>
</Popover>
</DialogTrigger>
)
}

View File

@@ -1,134 +0,0 @@
"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,
})

View File

@@ -1,142 +0,0 @@
.roomPackageFilter {
display: flex;
gap: var(--Space-x1);
flex-direction: column-reverse;
align-items: flex-start;
}
.selectedPackages {
display: flex;
gap: var(--Space-x1);
flex-wrap: wrap;
}
.modalOverlay {
position: fixed;
inset: 0;
background-color: var(--Overlay-40);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
.modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--Space-x2) var(--Space-x05);
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
.modalDialog {
display: grid;
gap: var(--Space-x2);
}
.dialog {
display: grid;
gap: var(--Space-x2);
max-width: 340px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--Space-x15);
}
.footer {
display: grid;
gap: var(--Space-x1);
padding: 0 var(--Space-x15);
}
.selectedPackage {
display: flex;
justify-content: center;
align-items: center;
padding: var(--Space-x1);
gap: var(--Space-x05);
border-radius: var(--Corner-radius-sm);
background-color: var(--Surface-Secondary-Default-dark);
color: var(--Text-Interactive-Default);
}
.removeButton {
background-color: transparent;
border-width: 0;
cursor: pointer;
padding: var(--Space-x05);
margin: calc(-1 * var(--Space-x05));
}
@media screen and (max-width: 767px) {
.popover {
display: none;
}
}
@media screen and (min-width: 768px) {
.roomPackageFilter {
flex-direction: row;
align-items: stretch;
}
.modalOverlay {
display: none;
}
.popover {
padding: var(--Space-x2);
border-radius: var(--Corner-radius-md);
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
max-width: 340px;
overflow-y: auto;
}
.checkboxContainer {
padding: 0 var(--Space-x1);
}
.header {
display: none;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

View File

@@ -1,19 +0,0 @@
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import type { MaterialSymbolProps } from "@scandic-hotels/design-system/Icons/MaterialIcon/MaterialSymbol"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
export function getIconNameByPackageCode(
packageCode: PackageEnum
): MaterialSymbolProps["icon"] {
switch (packageCode) {
case RoomPackageCodeEnum.PET_ROOM:
return "pets"
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
return "accessible"
case RoomPackageCodeEnum.ALLERGY_ROOM:
return "mode_fan"
default:
return "star"
}
}

View File

@@ -1,84 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
import { RoomPackageFilter } from "./RoomPackageFilter"
import styles from "./roomsHeader.module.css"
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
<InnerRoomsHeader roomIndex={roomIndex} />
</ErrorBoundary>
)
}
function InnerRoomsHeader({ roomIndex }: { roomIndex: number }) {
return (
<div className={styles.container}>
<AvailableRoomCount roomIndex={roomIndex} />
<div className={styles.filters}>
<RemoveBookingCodeButton />
<RoomPackageFilter roomIndex={roomIndex} />
{/* <BookingCodeFilter roomIndex={roomIndex} /> */}
</div>
</div>
)
}
function AvailableRoomCount({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const { isFetching, getAvailabilityForRoom } = useSelectRateContext()
const roomAvailability = getAvailabilityForRoom(roomIndex) ?? []
const availableRooms = roomAvailability.filter(
(x) => x.status === AvailabilityEnum.Available
).length
const totalRooms = roomAvailability.length
const notAllRoomsAvailableText = intl.formatMessage(
{
defaultMessage:
"{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
availableRooms,
numberOfRooms: totalRooms,
}
)
const allRoomsAvailableText = intl.formatMessage(
{
defaultMessage:
"{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
numberOfRooms: totalRooms,
}
)
if (isFetching) {
return <SkeletonShimmer height="30px" width="25ch" />
}
return (
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
<p>
{availableRooms !== totalRooms
? notAllRoomsAvailableText
: allRoomsAvailableText}
</p>
</Typography>
)
}

View File

@@ -1,21 +0,0 @@
.container {
display: grid;
gap: var(--Space-x3);
align-items: center;
}
.availableRooms {
color: var(--Text-Default);
}
.filters {
display: flex;
gap: var(--Space-x1);
align-items: flex-start;
}
@media screen and (min-width: 768px) {
.container {
grid-template-columns: 1fr auto;
}
}

View File

@@ -1,56 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
export default function RoomSize({ roomSize }: RoomSizeProps) {
const intl = useIntl()
if (!roomSize) {
return null
}
if (roomSize.min === roomSize.max) {
return (
<>
<Typography variant="Body/Supporting text (caption)/smBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p></p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<h4>
{intl.formatMessage(
{
defaultMessage: "{roomSize} m²",
},
{ roomSize: roomSize.min }
)}
</h4>
</Typography>
</>
)
}
return (
<>
<Typography variant="Body/Supporting text (caption)/smBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p></p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<h4>
{intl.formatMessage(
{
defaultMessage: "{roomSizeMin} - {roomSizeMax} m²",
},
{
roomSizeMin: roomSize.min,
roomSizeMax: roomSize.max,
}
)}
</h4>
</Typography>
</>
)
}

View File

@@ -1,42 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import useSidePeekStore from "@/stores/sidepeek"
import styles from "./details.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({
hotelId,
roomTypeCode,
}: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onClick={() =>
openSidePeek({
key: SidePeekEnum.roomDetails,
hotelId,
roomTypeCode,
})
}
size="Small"
variant="Text"
wrapping
typography="Body/Supporting text (caption)/smBold"
color="Inverted"
className={styles.sidePeekButton}
>
{intl.formatMessage({ defaultMessage: "View room details" })}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
)
}

View File

@@ -1,18 +0,0 @@
.specification {
align-items: center;
display: flex;
justify-content: center;
gap: var(--Space-x1);
}
.roomDetails {
display: flex;
flex-direction: column;
text-align: center;
gap: var(--Space-x1);
padding-bottom: var(--Space-x05);
}
.sidePeekButton {
width: 100%;
}

View File

@@ -1,56 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import RoomSize from "./RoomSize"
import styles from "./details.module.css"
import type { RoomInfo } from "@/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>
</>
)
}

View File

@@ -1,15 +0,0 @@
.message {
display: flex;
align-items: center;
text-align: center;
gap: var(--Space-x1);
}
.breakfastMessage {
flex: 0 1 auto;
}
.divider {
flex: 1;
min-width: 5%;
}

View File

@@ -1,31 +0,0 @@
import { HotelTypeEnum } from "@scandic-hotels/trpc/enums/hotelType"
export function getBreakfastMessage(
publicBreakfastIncluded: boolean,
memberBreakfastIncluded: boolean,
hotelType: string | undefined,
userIsLoggedIn: boolean,
msgs: Record<
"included" | "noSelection" | "scandicgo" | "notIncluded",
string
>,
roomNr: number
) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return msgs.scandicgo
}
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
return msgs.included
}
if (publicBreakfastIncluded && memberBreakfastIncluded) {
return msgs.included
}
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
return msgs.notIncluded
}
return msgs.notIncluded
}

View File

@@ -1,80 +0,0 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import { isValidClientSession } from "@/utils/clientSession"
import { getBreakfastMessage } from "./getBreakfastMessage"
import styles from "./breakfastMessage.module.css"
export function BreakfastMessage({
breakfastIncludedMember,
breakfastIncludedStandard,
hasRegularRates,
roomIndex,
}: {
breakfastIncludedMember: boolean
breakfastIncludedStandard: boolean
hasRegularRates: boolean
roomIndex: number
}) {
const intl = useIntl()
const { hotel } = useSelectRateContext()
const roomNr = roomIndex + 1
// TODO: Replace with context value when we have support for dropdown "Show all rates"
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
const hotelType = hotel.data?.hotel.hotelType
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const breakfastMessages = {
included: intl.formatMessage({
defaultMessage: "Breakfast is included.",
}),
notIncluded: intl.formatMessage({
defaultMessage: "Breakfast excluded, add in next step.",
}),
noSelection: intl.formatMessage({
defaultMessage: "Select a rate",
}),
scandicgo: intl.formatMessage({
defaultMessage: "Breakfast deal can be purchased at the hotel.",
}),
}
const breakfastMessage = getBreakfastMessage(
breakfastIncludedStandard,
breakfastIncludedMember,
hotelType,
isUserLoggedIn,
breakfastMessages,
roomNr
)
const isDiscount = selectedFilter === BookingCodeFilterEnum.Discounted
if (isDiscount || !hasRegularRates) {
return null
}
return (
<div className={styles.message}>
<Divider className={styles.divider} color="Border/Divider/Subtle" />
<Typography
variant={"Body/Supporting text (caption)/smRegular"}
className={styles.breakfastMessage}
>
<p>{breakfastMessage}</p>
</Typography>
<Divider className={styles.divider} color="Border/Divider/Subtle" />
</div>
)
}

View File

@@ -1,284 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import CampaignRateCard from "@scandic-hotels/design-system/CampaignRateCard"
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type {
AvailabilityWithRoomInfo,
Package,
} from "@/contexts/SelectRate/types"
type CampaignProps = {
nights: number
campaign: AvailabilityWithRoomInfo["campaign"]
roomIndex: number
roomTypeCode: string
selectedPackages: Package[]
}
export default function Campaign({
campaign,
roomIndex,
nights,
roomTypeCode,
selectedPackages,
}: CampaignProps) {
// TODO: Replace with context value when we have support for dropdown "Show all rates"
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
const isCampaignRate = campaign.some(
(c) =>
c.rateDefinition.isCampaignRate || c.rateDefinitionMember?.isCampaignRate
)
if (selectedFilter === BookingCodeFilterEnum.Discounted && !isCampaignRate) {
return null
}
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
campaign = campaign.filter((product) => product.bookingCode)
}
return campaign.map((product, ix) => {
return (
<Inner
key={ix}
product={product}
nights={nights}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
selectedPackages={selectedPackages}
/>
)
})
}
function Inner({
product,
roomIndex,
roomTypeCode,
selectedPackages,
nights,
}: {
roomIndex: number
nights: number
roomTypeCode: string
product: AvailabilityWithRoomInfo["campaign"][number]
selectedPackages: Package[]
}) {
const roomNr = roomIndex + 1
const {
isRateSelected,
actions: { selectRate },
} = useSelectRateContext()
const rateTitles = useRateTitles()
const isUserLoggedIn = useIsUserLoggedIn()
const intl = useIntl()
const night = intl
.formatMessage({
defaultMessage: "night",
})
.toUpperCase()
const standardPriceMsg = intl.formatMessage({
defaultMessage: "Standard price",
})
const memberPriceMsg = intl.formatMessage({
defaultMessage: "Member price",
})
if (!product.public) {
return (
<NoRateAvailableCard
key={product.rate}
noPricesAvailableText={rateTitles.noPriceAvailable}
paymentTerm={rateTitles[product.rate].paymentTerm}
rateTitle={rateTitles[product.rate].title}
variant="Campaign"
/>
)
}
const rateTermDetails = product.rateDefinitionMember
? [
{
title: product.bookingCode
? product.rateDefinition.title
: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
{
title: product.bookingCode
? product.rateDefinitionMember.title
: memberPriceMsg,
terms: product.rateDefinitionMember.generalTerms,
},
]
: [
{
title: product.bookingCode
? product.rateDefinition.title
: standardPriceMsg,
terms: product.rateDefinition.generalTerms,
},
]
const isSelected = isRateSelected({
roomIndex,
rate: { ...product, type: "campaign" },
roomTypeCode,
})
let bannerText = intl.formatMessage({
defaultMessage: "Campaign",
})
if (product.bookingCode) {
bannerText = product.bookingCode
}
if (product.rateDefinition.breakfastIncluded) {
bannerText = `${bannerText}${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
} else {
bannerText = `${bannerText}${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`
}
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const pricePerNight = calculatePricePerNightPriceProduct(
product.public.localPrice.pricePerNight,
product.public.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
const pricePerNightMember = product.member
? calculatePricePerNightPriceProduct(
product.member.localPrice.pricePerNight,
product.member.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
: undefined
const isMainRoom = roomIndex === 0
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
let approximateRatePrice = undefined
if (isMainRoomAndLoggedIn && pricePerNightMember) {
approximateRatePrice = pricePerNightMember.totalRequestedPrice
} else if (
pricePerNight.totalRequestedPrice &&
pricePerNightMember?.totalRequestedPrice
) {
approximateRatePrice = `${pricePerNight.totalRequestedPrice}/${pricePerNightMember.totalRequestedPrice}`
} else if (pricePerNight.totalRequestedPrice) {
approximateRatePrice = pricePerNight.totalRequestedPrice
}
const approximateRate =
approximateRatePrice && product.public.requestedPrice
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: approximateRatePrice,
unit: product.public.requestedPrice.currency,
}
: undefined
const rateCode = isMainRoomAndLoggedIn
? product.member!.rateCode
: product.public!.rateCode
const counterRateCode = isMainRoomAndLoggedIn
? product.public?.rateCode
: product.member?.rateCode
const campaignMemberLabel =
product.rateDefinitionMember?.title || memberPriceMsg
return (
<CampaignRateCard
key={product.rate}
approximateRate={approximateRate}
bannerText={bannerText}
handleChange={() =>
selectRate({
roomIndex,
rateCode: rateCode,
counterRateCode: counterRateCode,
roomTypeCode,
bookingCode: product.bookingCode,
})
}
isSelected={isSelected}
isHighlightedRate={
!!product.rateDefinition?.displayPriceRed || isMainRoomAndLoggedIn
}
memberRate={
pricePerNightMember && !isMainRoomAndLoggedIn
? {
label: memberPriceMsg,
price: pricePerNightMember.totalPrice,
unit: `${product.member!.localPrice.currency}/${night}`,
}
: undefined
}
comparisonRate={
isMainRoomAndLoggedIn
? {
price: pricePerNight.totalPrice,
unit: product.public.localPrice.currency,
}
: undefined
}
name={`rateCode-${roomNr}-${product.public.rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}
rate={{
label: isMainRoomAndLoggedIn ? campaignMemberLabel : standardPriceMsg,
price:
isMainRoomAndLoggedIn && pricePerNightMember
? pricePerNightMember.totalPrice
: pricePerNight.totalPrice,
unit: `${product.public.localPrice.currency}/${night}`,
}}
rateTitle={rateTitles[product.rate].title}
omnibusRate={
product.public.localPrice.omnibusPricePerNight
? {
label: intl
.formatMessage({
defaultMessage: "Lowest price (last 30 days)",
})
.toUpperCase(),
price: product.public.localPrice.omnibusPricePerNight.toString(),
unit: product.public.localPrice.currency,
}
: undefined
}
rateTermDetails={rateTermDetails}
value={product.public.rateCode}
/>
)
}

View File

@@ -1,390 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type { Package } from "@/contexts/SelectRate/types"
type CodeProps = {
nights: number
roomTypeCode: string
code: CodeProduct[]
roomIndex: number
selectedPackages: Package[]
}
export default function Code({
code,
nights,
roomTypeCode,
roomIndex,
selectedPackages,
}: CodeProps) {
return code.map((product) => {
return (
<InnerCode
key={product.rate}
codeProduct={product}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
nights={nights}
selectedPackages={selectedPackages}
/>
)
})
}
function InnerCode({
codeProduct,
roomIndex,
roomTypeCode,
nights,
selectedPackages,
}: {
codeProduct: CodeProduct
roomIndex: number
roomTypeCode: string
nights: number
selectedPackages: Package[]
}) {
const {
input: { bookingCode },
actions: { selectRate },
isRateSelected,
} = useSelectRateContext()
function handleSelectRate(rateCode: string) {
selectRate({ roomIndex, rateCode, roomTypeCode })
}
const bannerText = useBannerText({
bookingCode: bookingCode ?? "",
breakfastIncluded: codeProduct.rateDefinition.breakfastIncluded,
})
const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
const isSelected = isRateSelected({
roomIndex,
roomTypeCode,
rate: { ...codeProduct, type: "code" },
})
if ("corporateCheque" in codeProduct) {
return (
<CorporateChequeCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
if ("voucher" in codeProduct) {
return (
<VoucherCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
if (codeProduct.public) {
return (
<PublicCode
codeProduct={codeProduct}
roomIndex={roomIndex}
roomTypeCode={roomTypeCode}
bannerText={bannerText}
packagesSum={pkgsSum}
packagesSumRequested={pkgsSumRequested}
nights={nights}
handleSelectRate={handleSelectRate}
isSelected={isSelected}
/>
)
}
return null
}
function useBannerText({
bookingCode,
breakfastIncluded,
}: {
breakfastIncluded: boolean
bookingCode: string
}) {
const intl = useIntl()
if (breakfastIncluded) {
return `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
} else {
return `${bookingCode}${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`
}
}
function CorporateChequeCode({
codeProduct,
roomIndex,
bannerText,
packagesSum,
handleSelectRate,
isSelected,
}: {
codeProduct: Extract<CodeProduct, { corporateCheque: any }>
roomIndex: number
roomTypeCode: string
bannerText: string
packagesSum: ReturnType<typeof sumPackages>
handleSelectRate: (rateCode: string) => void
isSelected: boolean
}) {
const roomNr = roomIndex + 1
const intl = useIntl()
const rateTitles = useRateTitles()
const { localPrice, rateCode, requestedPrice } = codeProduct.corporateCheque
const rateTermDetails = getRateTermDetails(codeProduct)
let price = `${localPrice.numberOfCheques} CC`
if (localPrice.additionalPricePerStay) {
price = `${price} + ${localPrice.additionalPricePerStay + packagesSum.price}`
} else if (packagesSum.price) {
price = `${price} + ${packagesSum.price}`
}
const currency =
localPrice.additionalPricePerStay > 0 || packagesSum.price > 0
? (localPrice.currency ?? packagesSum.currency ?? "")
: ""
const approximateRate =
requestedPrice?.additionalPricePerStay && requestedPrice?.currency
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price:
`${requestedPrice.numberOfCheques} CC + ` +
requestedPrice.additionalPricePerStay,
unit: requestedPrice.currency,
}
: undefined
return (
<CodeRateCard
key={codeProduct.rate}
approximateRate={approximateRate}
bannerText={bannerText}
handleChange={() =>
handleSelectRate(codeProduct.corporateCheque.rateCode)
}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
rate={{
label: codeProduct.rateDefinition?.title,
price,
unit: currency,
}}
rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
function PublicCode({
codeProduct,
roomIndex,
bannerText,
packagesSum,
packagesSumRequested,
nights,
handleSelectRate,
isSelected,
}: {
codeProduct: Extract<CodeProduct, { public: unknown }>
roomIndex: number
roomTypeCode: string
bannerText: string
packagesSum: ReturnType<typeof sumPackages>
packagesSumRequested: ReturnType<typeof sumPackagesRequestedPrice>
nights: number
handleSelectRate: (rateCode: string) => void
isSelected: boolean
}) {
const roomNr = roomIndex + 1
const intl = useIntl()
const rateTitles = useRateTitles()
if (!codeProduct.public) {
return null
}
const rateTermDetails = getRateTermDetails(codeProduct)
const night = intl
.formatMessage({
defaultMessage: "night",
})
.toUpperCase()
const { localPrice, rateCode, requestedPrice } = codeProduct.public
const pricePerNight = calculatePricePerNightPriceProduct(
localPrice.pricePerNight,
requestedPrice?.pricePerNight,
nights,
packagesSum.price,
packagesSumRequested.price
)
const approximateRate =
pricePerNight.totalRequestedPrice && requestedPrice?.currency
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: pricePerNight.totalRequestedPrice,
unit: requestedPrice.currency,
}
: undefined
const regularPricePerNight = calculatePricePerNightPriceProduct(
localPrice.regularPricePerNight,
requestedPrice?.regularPricePerNight,
nights,
packagesSum.price,
packagesSumRequested.price
)
const comparisonRate =
+regularPricePerNight.totalPrice > +pricePerNight.totalPrice
? {
price: regularPricePerNight.totalPrice,
unit: localPrice.currency,
}
: undefined
return (
<CodeRateCard
key={codeProduct.rate}
approximateRate={approximateRate}
bannerText={bannerText}
comparisonRate={comparisonRate}
handleChange={() => handleSelectRate(codeProduct.public!.rateCode)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
rate={{
label: codeProduct.rateDefinition?.title,
price: pricePerNight.totalPrice,
unit: `${localPrice.currency}/${night}`,
}}
rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
function VoucherCode({
codeProduct,
bannerText,
packagesSum,
roomIndex,
handleSelectRate,
isSelected,
}: {
codeProduct: Extract<CodeProduct, { voucher: any }>
roomIndex: number
roomTypeCode: string
bannerText: string
packagesSum: ReturnType<typeof sumPackages>
handleSelectRate: (rateCode: string) => void
isSelected: boolean
}) {
const roomNr = roomIndex + 1
const intl = useIntl()
const rateTitles = useRateTitles()
const { numberOfVouchers, rateCode } = codeProduct.voucher
const rateTermDetails = getRateTermDetails(codeProduct)
const voucherMsg = intl
.formatMessage({
defaultMessage: "Voucher",
})
.toUpperCase()
let price = `${numberOfVouchers} ${voucherMsg}`
if (packagesSum.price) {
price = `${price} + ${packagesSum.price}`
}
return (
<CodeRateCard
key={codeProduct.rate}
bannerText={bannerText}
handleChange={() => handleSelectRate(codeProduct.voucher.rateCode)}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
rate={{
label: codeProduct.rateDefinition?.title,
price,
unit: packagesSum.currency ?? "",
}}
rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails}
value={rateCode}
/>
)
}
function getRateTermDetails(codeProduct: CodeProduct): RateTermDetails {
return codeProduct.rateDefinitionMember
? [
{
title: codeProduct.rateDefinition.title,
terms: codeProduct.rateDefinition.generalTerms,
},
{
title: codeProduct.rateDefinitionMember.title,
terms: codeProduct.rateDefinitionMember.generalTerms,
},
]
: [
{
title: codeProduct.rateDefinition.title,
terms: codeProduct.rateDefinition.generalTerms,
},
]
}
type RateTermDetails = { title: string; terms: string[] }[]

View File

@@ -1,130 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
import { sumPackages } from "@/components/HotelReservation/utils"
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
import useRateTitles from "@/hooks/booking/useRateTitles"
import type {
AvailabilityWithRoomInfo,
Package,
} from "@/contexts/SelectRate/types"
type RedemptionsProps = {
redemptions: AvailabilityWithRoomInfo["redemptions"]
roomTypeCode: string
selectedPackages: Package[]
roomIndex: number
}
export default function Redemptions({
redemptions,
roomTypeCode,
roomIndex,
selectedPackages,
}: RedemptionsProps) {
const intl = useIntl()
const rateTitles = useRateTitles()
const {
actions: { selectRate },
selectedRates,
} = useSelectRateContext()
// TODO: Replace with context value when we have support for dropdown "Show all rates"
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
const selectedRate = selectedRates.forRoom(roomIndex)
if (
selectedFilter === BookingCodeFilterEnum.Discounted ||
!redemptions.length
) {
return null
}
const rewardNight = intl.formatMessage({
defaultMessage: "Reward night",
})
const pkgsSum = sumPackages(selectedPackages)
const breakfastIncluded = intl.formatMessage({
defaultMessage: "Breakfast included",
})
const breakfastExcluded = intl.formatMessage({
defaultMessage: "Breakfast excluded",
})
const selectedRateCode =
selectedRate &&
"redemption" in selectedRate &&
selectedRate.roomInfo.roomTypeCode === roomTypeCode
? selectedRate.redemption.rateCode
: ""
const rates = redemptions.map((r) => {
let additionalPrice
if (r.redemption.localPrice.additionalPricePerStay) {
additionalPrice =
r.redemption.localPrice.additionalPricePerStay + pkgsSum.price
} else if (pkgsSum.price) {
additionalPrice = pkgsSum.price
}
let additionalPriceCurrency
if (r.redemption.localPrice.currency) {
additionalPriceCurrency = r.redemption.localPrice.currency
} else if (pkgsSum.currency) {
additionalPriceCurrency = pkgsSum.currency
}
return {
additionalPrice:
additionalPrice && additionalPriceCurrency
? {
currency: additionalPriceCurrency,
price: additionalPrice.toString(),
}
: undefined,
currency: "PTS",
isDisabled: !r.redemption.hasEnoughPoints,
points: r.redemption.localPrice.pointsPerStay.toString(),
rateCode: r.redemption.rateCode,
}
})
const notEnoughPoints = rates.every((rate) => rate.isDisabled)
const firstRedemption = redemptions[0]
const bannerText = firstRedemption.rateDefinition.breakfastIncluded
? `${rewardNight}${breakfastIncluded}`
: `${rewardNight}${breakfastExcluded}`
const rateTermDetails = [
{
title: rateTitles[firstRedemption.rate].title,
terms: firstRedemption.rateDefinition.generalTerms,
},
]
return (
<PointsRateCard
key={firstRedemption.rate}
bannerText={bannerText}
onRateSelect={(rateCode: string) => {
selectRate({
roomIndex: roomIndex,
rateCode: rateCode,
roomTypeCode: roomTypeCode,
})
}}
paymentTerm={rateTitles[firstRedemption.rate].paymentTerm}
rates={rates}
rateTitle={rateTitles[firstRedemption.rate].title}
rateTermDetails={rateTermDetails}
selectedRate={selectedRateCode}
isNotEnoughPoints={notEnoughPoints}
notEnoughPointsText={intl.formatMessage({
defaultMessage: "Not enough points",
})}
/>
)
}

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