Merged in feat/SW-1379-multiroom-summary (pull request #1198)

Feat/SW-1379 multiroom summary

* fix: added early return in hotel query and added missing type annotations

* feat(SW-1379): update summary to support multiple rooms and add tests

* fix: added check for room number when using isMember for member prices

* fix: remove mocked array

* fix: minor bug fixes in rate details popup

* fix: translation key


Approved-by: Pontus Dreij
Approved-by: Arvid Norlin
This commit is contained in:
Tobias Johansson
2025-01-29 09:25:43 +00:00
parent e29cb283db
commit a7468cd958
17 changed files with 781 additions and 382 deletions

View File

@@ -0,0 +1,138 @@
import { BedTypeEnum } from "@/constants/booking"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
DetailsSchema,
SignedInDetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { PackageTypeEnum } from "@/types/enums/packages"
import type { RoomPrice, RoomRate } from "@/types/stores/enter-details"
export const booking: SelectRateSearchParams = {
city: "Stockholm",
hotelId: "811",
fromDate: "2030-01-01",
toDate: "2030-01-03",
rooms: [
{
adults: 2,
roomTypeCode: "",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
],
}
export const breakfastPackage: BreakfastPackage = {
code: "BRF1",
description: "Breakfast with reservation",
localPrice: { currency: "SEK", price: "99", totalPrice: "99" },
requestedPrice: {
currency: "EUR",
price: "9",
totalPrice: "9",
},
packageType: PackageTypeEnum.BreakfastAdult as const,
}
export const roomRate: RoomRate = {
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
pricePerNight: 1508,
pricePerStay: 1508,
currency: "SEK",
},
requestedPrice: {
pricePerNight: 132,
pricePerStay: 132,
currency: "EUR",
},
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
pricePerNight: 1525,
pricePerStay: 1525,
currency: "SEK",
},
requestedPrice: {
pricePerNight: 133,
pricePerStay: 133,
currency: "EUR",
},
},
}
export const roomPrice: RoomPrice = {
perNight: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
perStay: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
}
export const bedType: { [x: string]: BedTypeSelection } = {
king: {
type: BedTypeEnum.King,
description: "King-size bed",
value: "SKS",
size: {
min: 180,
max: 200,
},
extraBed: undefined,
},
queen: {
type: BedTypeEnum.Queen,
description: "Queen-size bed",
value: "QZ",
size: {
min: 160,
max: 200,
},
extraBed: undefined,
},
}
export const guestDetailsNonMember: DetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
}
export const guestDetailsMember: SignedInDetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
zipCode: "12345",
dateOfBirth: "1999-01-01",
membershipNo: "12421412211212",
}

View File

@@ -1,13 +1,74 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SidePanel from "@/components/HotelReservation/SidePanel" import SidePanel from "@/components/HotelReservation/SidePanel"
import SummaryUI from "./UI" import SummaryUI from "./UI"
import type { SummaryProps } from "@/types/components/hotelReservation/summary" import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
guest: state.guest,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
totalPrice: state.totalPrice,
vat: state.vat,
}
}
export default function DesktopSummary(props: SummaryProps) { export default function DesktopSummary(props: SummaryProps) {
const {
bedType,
booking,
breakfast,
guest,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
totalPrice,
vat,
} = useEnterDetailsStore(storeSelector)
// TODO: rooms should be part of store
const rooms = [
{
adults: booking.rooms[0].adults,
childrenInRoom: booking.rooms[0].childrenInRoom,
bedType,
breakfast,
guest,
roomRate,
roomPrice,
roomType: props.roomType,
rateDetails: props.rateDetails,
cancellationText: props.cancellationText,
},
]
return ( return (
<SidePanel variant="summary"> <SidePanel variant="summary">
<SummaryUI {...props} /> <SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
packages={packages}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
/>
</SidePanel> </SidePanel>
) )
} }

View File

@@ -49,11 +49,15 @@
opacity: 1; opacity: 1;
} }
.content,
.priceDetailsButton { .priceDetailsButton {
overflow: hidden; overflow: hidden;
} }
.content {
max-height: 50dvh;
overflow-y: auto;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.bottomSheet { .bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7); padding: var(--Spacing-x2) 0 var(--Spacing-x7);

View File

@@ -14,20 +14,67 @@ import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) { function storeSelector(state: DetailsState) {
return { return {
join: state.guest.join, bedType: state.bedType,
membershipNo: state.guest.membershipNo, booking: state.booking,
breakfast: state.breakfast,
guest: state.guest,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
totalPrice: state.totalPrice,
vat: state.vat,
} }
} }
export default function MobileSummary(props: SummaryProps) { export default function MobileSummary(props: SummaryProps) {
const { join, membershipNo } = useEnterDetailsStore(storeSelector) const {
const showPromo = !props.isMember && !join && !membershipNo bedType,
booking,
breakfast,
guest,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
totalPrice,
vat,
} = useEnterDetailsStore(storeSelector)
// TODO: rooms should be part of store
const rooms = [
{
adults: booking.rooms[0].adults,
childrenInRoom: booking.rooms[0].childrenInRoom,
bedType,
breakfast,
guest,
roomRate,
roomPrice,
roomType: props.roomType,
rateDetails: props.rateDetails,
cancellationText: props.cancellationText,
},
]
const showPromo = !props.isMember && !guest.join && !guest.membershipNo
return ( return (
<div className={styles.mobileSummary}> <div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null} {showPromo ? <SignupPromoMobile /> : null}
<SummaryBottomSheet> <SummaryBottomSheet>
<div className={styles.wrapper}> <div className={styles.wrapper}>
<SummaryUI {...props} /> <SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
packages={packages}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
/>
</div> </div>
</SummaryBottomSheet> </SummaryBottomSheet>
</div> </div>

View File

@@ -84,7 +84,7 @@ export default function PriceDetailsTable({
const diff = dt(booking.toDate).diff(booking.fromDate, "days") const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage( const nights = intl.formatMessage(
{ id: "booking.nights" }, { id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff } { totalNights: diff }
) )
const vatPercentage = vat / 100 const vatPercentage = vat / 100
@@ -135,7 +135,7 @@ export default function PriceDetailsTable({
)} )}
value={formatPrice( value={formatPrice(
intl, intl,
parseInt(breakfast.localPrice.totalPrice), parseInt(breakfast.localPrice.price),
breakfast.localPrice.currency breakfast.localPrice.currency
)} )}
/> />

View File

@@ -1,9 +1,9 @@
"use client" "use client"
import React from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop" import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import { import {
@@ -26,84 +26,22 @@ import PriceDetailsTable from "../PriceDetailsTable"
import styles from "./ui.module.css" import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { SummaryProps } from "@/types/components/hotelReservation/summary" import type { SummaryUIProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details" import type { DetailsProviderProps } from "@/types/providers/enter-details"
export function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
join: state.guest.join,
membershipNo: state.guest.membershipNo,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
totalPrice: state.totalPrice,
vat: state.vat,
}
}
export default function SummaryUI({ export default function SummaryUI({
cancellationText, booking,
rooms,
packages,
totalPrice,
isMember, isMember,
rateDetails,
roomType,
breakfastIncluded, breakfastIncluded,
}: SummaryProps) { toggleSummaryOpen,
togglePriceDetailsModalOpen,
}: SummaryUIProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const {
bedType,
booking,
breakfast,
join,
membershipNo,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
totalPrice,
vat,
} = useEnterDetailsStore(storeSelector)
// TODO: Update for Multiroom later
const adults = booking.rooms[0].adults
const children = booking.rooms[0].childrenInRoom
const childrenBeds = children?.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 = roomRate.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
const showMemberPrice = !!(isMember || join || membershipNo) && memberPrice
const diff = dt(booking.toDate).diff(booking.fromDate, "days") const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage( const nights = intl.formatMessage(
@@ -123,22 +61,24 @@ export default function SummaryUI({
} }
} }
const adultsMsg = intl.formatMessage( function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) {
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" }, return roomRate.memberRate
{ totalAdults: adults } ? {
) currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
const guestsParts = [adultsMsg] amount: roomRate.memberRate.localPrice.pricePerStay,
if (children?.length) { }
const childrenMsg = intl.formatMessage( : null
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: children.length }
)
guestsParts.push(childrenMsg)
} }
const showSignupPromo =
rooms.length === 1 &&
rooms
.slice(0, 1)
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
const memberPrice = getMemberPrice(rooms[0].roomRate)
return ( return (
<section className={styles.summary}> <section className={styles.summary}>
<header className={styles.header}> <header className={styles.header}>
@@ -160,171 +100,255 @@ export default function SummaryUI({
</Button> </Button>
</header> </header>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
<div className={styles.addOns}> {rooms.map((room, idx) => {
<div> const roomNumber = idx + 1
<div className={styles.entry}> const adults = room.adults
<Body color="uiTextHighContrast">{roomType}</Body> const childrenInRoom = room.childrenInRoom
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice( const childrenBeds = childrenInRoom?.reduce(
intl, (acc, value) => {
roomPrice.perStay.local.price, const bedType = Number(value.bed)
roomPrice.perStay.local.currency if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
)} return acc
</Body>
</div>
<Caption color="uiTextMediumContrast">
{guestsParts.join(", ")}
</Caption>
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
} }
title={cancellationText} const count = acc.get(bedType) ?? 0
> acc.set(bedType, count + 1)
<div className={styles.terms}> return acc
{rateDetails?.map((info) => ( },
<Body new Map<ChildBedMapEnum, number>([
key={info} [ChildBedMapEnum.IN_CRIB, 0],
color="uiTextHighContrast" [ChildBedMapEnum.IN_EXTRA_BED, 0],
className={styles.termsText} ])
> )
<CheckIcon
color="uiSemanticSuccess" const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
width={20} const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
height={20}
className={styles.termsIcon} const memberPrice = getMemberPrice(room.roomRate)
></CheckIcon>
{info} const isFirstRoomMember = roomNumber === 1 && isMember
</Body> const showMemberPrice =
))} !!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
</div> memberPrice
</Modal>
</div> const adultsMsg = intl.formatMessage(
{packages { id: "{totalAdults, plural, one {# adult} other {# adults}}" },
? packages.map((roomPackage) => ( { totalAdults: adults }
<div className={styles.entry} key={roomPackage.code}> )
<div>
<Body color="uiTextHighContrast"> const guestsParts = [adultsMsg]
{roomPackage.description} if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
return (
<React.Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
>
<div>
{rooms.length > 1 ? (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {roomNumber}
</Body>
) : null}
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Body> </Body>
</div> </div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(roomPackage.localPrice.price),
roomPackage.localPrice.currency
)}
</Body>
</div>
))
: null}
{bedType ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">{bedType.description}</Body>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, roomPrice.perStay.local.currency)}
</Body>
</div>
) : null}
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Crib (child) × {count}" },
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, roomPrice.perStay.local.currency)}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Extra bed (child) × {count}" },
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, roomPrice.perStay.local.currency)}
</Body>
</div>
) : null}
{breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
</Body>
</div>
) : breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, roomPrice.perStay.local.currency)}
</Body>
</div>
) : null}
{breakfast ? (
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
id: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(breakfast.localPrice.totalPrice),
breakfast.localPrice.currency
)}
</Body>
</div>
{children?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{intl.formatMessage( {guestsParts.join(", ")}
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: children.length }
)}
</Caption> </Caption>
<Body color="uiTextHighContrast"> <Caption color="uiTextMediumContrast">
{formatPrice(intl, 0, breakfast.localPrice.currency)} {room.cancellationText}
</Body> </Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
}
title={room.cancellationText}
>
<div className={styles.terms}>
{room.rateDetails?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
</div> </div>
) : null} {packages
</div> ? packages.map((roomPackage) => (
) : null} <div className={styles.entry} key={roomPackage.code}>
</div> <div>
<Divider color="primaryLightSubtle" /> <Body color="uiTextHighContrast">
{roomPackage.description}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(roomPackage.localPrice.price),
roomPackage.localPrice.currency
)}
</Body>
</div>
))
: null}
{room.bedType ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{room.bedType.description}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Crib (child) × {count}" },
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Extra bed (child) × {count}" },
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
</Body>
</div>
) : room.breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{room.breakfast ? (
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
id: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(room.breakfast.localPrice.totalPrice),
room.breakfast.localPrice.currency
)}
</Body>
</div>
{childrenInRoom?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.breakfast.localPrice.currency
)}
</Body>
</div>
) : null}
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
</React.Fragment>
)
})}
<div className={styles.total}> <div className={styles.total}>
<div className={styles.entry}> <div className={styles.entry}>
<div> <div>
@@ -334,7 +358,6 @@ export default function SummaryUI({
{ b: (str) => <b>{str}</b> } { b: (str) => <b>{str}</b> }
)} )}
</Body> </Body>
<Modal <Modal
title={intl.formatMessage({ id: "Price details" })} title={intl.formatMessage({ id: "Price details" })}
trigger={ trigger={
@@ -350,11 +373,12 @@ export default function SummaryUI({
</Button> </Button>
} }
> >
<PriceDetailsTable roomType={roomType} /> {/* // TODO: all rooms needs to be passed to PriceDetails */}
<PriceDetailsTable roomType={rooms[0].roomType} />
</Modal> </Modal>
</div> </div>
<div> <div>
<Body textTransform="bold"> <Body textTransform="bold" data-testid="total-price">
{formatPrice( {formatPrice(
intl, intl,
totalPrice.local.price, totalPrice.local.price,
@@ -379,7 +403,7 @@ export default function SummaryUI({
</div> </div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" /> <Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div> </div>
{!showMemberPrice && memberPrice ? ( {showSignupPromo && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} /> <SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null} ) : null}
</section> </section>

View File

@@ -39,6 +39,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
} }
.rateDetailsPopover { .rateDetailsPopover {

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from "@jest/globals"
import { act, cleanup, render, screen, within } from "@testing-library/react"
import { type IntlConfig, IntlProvider } from "react-intl"
import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsMember,
guestDetailsNonMember,
roomPrice,
roomRate,
} from "@/__mocks__/hotelReservation"
import { initIntl } from "@/i18n"
import SummaryUI from "./UI"
import type { PropsWithChildren } from "react"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
}))
function createWrapper(intlConfig: IntlConfig) {
return function Wrapper({ children }: PropsWithChildren) {
return (
<IntlProvider
messages={intlConfig.messages}
locale={intlConfig.locale}
defaultLocale={intlConfig.defaultLocale}
>
{children}
</IntlProvider>
)
}
}
// TODO: add type definition to this object
export const rooms = [
{
adults: 2,
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
bedType: {
description: bedType.queen.description,
roomTypeCode: bedType.queen.value,
},
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
},
{
adults: 1,
childrenInRoom: [],
bedType: {
description: bedType.king.description,
roomTypeCode: bedType.king.value,
},
breakfast: undefined,
guest: guestDetailsMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
},
]
describe("EnterDetails Summary", () => {
afterEach(() => {
cleanup()
})
test("render with single room correctly", async () => {
const intl = await initIntl(Lang.en)
await act(async () => {
render(
<SummaryUI
booking={booking}
rooms={rooms.slice(0, 1)}
isMember={false}
breakfastIncluded={false}
packages={[]}
totalPrice={{
requested: {
currency: "EUR",
price: 133,
},
local: {
currency: "SEK",
price: 1500,
},
}}
vat={12}
toggleSummaryOpen={jest.fn()}
togglePriceDetailsModalOpen={jest.fn()}
/>,
{
wrapper: createWrapper(intl),
}
)
})
screen.getByText("2 adults, 1 child")
screen.getByText("Standard")
screen.getByText("1,525 SEK")
screen.getByText(bedType.queen.description)
screen.getByText("Breakfast buffet")
screen.getByText("1,500 SEK")
screen.getByTestId("signup-promo-desktop")
})
test("render with multiple rooms correctly", async () => {
const intl = await initIntl(Lang.en)
await act(async () => {
render(
<SummaryUI
booking={booking}
rooms={rooms}
isMember={false}
breakfastIncluded={false}
packages={[]}
totalPrice={{
requested: {
currency: "EUR",
price: 133,
},
local: {
currency: "SEK",
price: 1500,
},
}}
vat={12}
toggleSummaryOpen={jest.fn()}
togglePriceDetailsModalOpen={jest.fn()}
/>,
{
wrapper: createWrapper(intl),
}
)
})
const room1 = within(screen.getByTestId("summary-room-1"))
room1.getByText("Standard")
room1.getByText("2 adults, 1 child")
room1.getByText(bedType.queen.description)
room1.getByText("Breakfast buffet")
const room2 = within(screen.getByTestId("summary-room-2"))
room2.getByText("Standard")
room2.getByText("1 adult")
const room2Breakfast = room2.queryByText("Breakfast buffet")
expect(room2Breakfast).not.toBeInTheDocument()
room2.getByText(bedType.king.description)
})
})

View File

@@ -22,7 +22,10 @@ export default function SignupPromoDesktop({
const price = formatPrice(intl, amount, currency) const price = formatPrice(intl, amount, currency)
return memberPrice ? ( return memberPrice ? (
<div className={styles.memberDiscountBannerDesktop}> <div
className={styles.memberDiscountBannerDesktop}
data-testid="signup-promo-desktop"
>
{badgeContent && <span className={styles.badge}>{badgeContent}</span>} {badgeContent && <span className={styles.badge}>{badgeContent}</span>}
<Footnote color="burgundy"> <Footnote color="burgundy">
{intl.formatMessage<React.ReactNode>( {intl.formatMessage<React.ReactNode>(

View File

@@ -10,6 +10,13 @@ import { getLocalizedMonthName } from "@/utils/dateFormatting"
import Date from "./index" import Date from "./index"
jest.mock("react-intl", () => ({
useIntl: () => ({
formatMessage: (message: { id: string }) => message.id,
formatNumber: (value: number) => value,
}),
}))
interface FormWrapperProps { interface FormWrapperProps {
defaultValues: Record<string, unknown> defaultValues: Record<string, unknown>
children: React.ReactNode children: React.ReactNode

View File

@@ -7,7 +7,7 @@ import { Lang } from "@/constants/languages"
const cache = createIntlCache() const cache = createIntlCache()
async function initIntl(lang: Lang) { export async function initIntl(lang: Lang) {
return createIntl<React.ReactNode>( return createIntl<React.ReactNode>(
{ {
defaultLocale: Lang.en, defaultLocale: Lang.en,

View File

@@ -1,11 +1,6 @@
import "@testing-library/jest-dom/jest-globals"
import "@testing-library/jest-dom" import "@testing-library/jest-dom"
jest.mock("react-intl", () => ({
useIntl: () => ({
formatMessage: (message: { id: string }) => message.id,
}),
}))
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
useRouter: jest.fn(), useRouter: jest.fn(),
usePathname: jest.fn().mockReturnValue("/"), usePathname: jest.fn().mockReturnValue("/"),

View File

@@ -1129,17 +1129,17 @@ export const hotelQueryRouter = router({
} }
const countries = await getCountries(options, searchParams, ctx.lang) const countries = await getCountries(options, searchParams, ctx.lang)
if (!countries) {
let citiesByCountry = null return null
if (countries) {
citiesByCountry = await getCitiesByCountry(
countries,
options,
searchParams,
ctx.lang
)
} }
const citiesByCountry = await getCitiesByCountry(
countries,
options,
searchParams,
ctx.lang
)
const locations = await getLocations( const locations = await getLocations(
ctx.lang, ctx.lang,
options, options,

View File

@@ -2,15 +2,19 @@ import { describe, expect, test } from "@jest/globals"
import { act, renderHook } from "@testing-library/react" import { act, renderHook } from "@testing-library/react"
import { type PropsWithChildren } from "react" import { type PropsWithChildren } from "react"
import { BedTypeEnum } from "@/constants/booking"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsNonMember,
roomRate,
} from "@/__mocks__/hotelReservation"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { detailsStorageName, useEnterDetailsStore } from "." import { detailsStorageName, useEnterDetailsStore } from "."
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { PackageTypeEnum } from "@/types/enums/packages"
import { StepEnum } from "@/types/enums/step" import { StepEnum } from "@/types/enums/step"
import type { PersistedState } from "@/types/stores/enter-details" import type { PersistedState } from "@/types/stores/enter-details"
@@ -27,100 +31,14 @@ jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn), fetchRetry: jest.fn((fn) => fn),
})) }))
const booking = {
hotelId: "123",
fromDate: "2100-01-01",
toDate: "2100-01-02",
rooms: [
{
adults: 1,
roomTypeCode: "SKS",
rateCode: "SAVEEU",
counterRateCode: "PLSA2BEU",
},
],
}
const bedTypes = [
{
type: BedTypeEnum.King,
description: "King-size bed",
value: "SKS",
size: {
min: 180,
max: 200,
},
roomTypeCode: "SKS",
extraBed: undefined,
},
{
type: BedTypeEnum.Queen,
description: "Queen-size bed",
value: "QZ",
size: {
min: 160,
max: 200,
},
roomTypeCode: "QZ",
extraBed: undefined,
},
]
const guest = {
countryCode: "SE",
dateOfBirth: "",
email: "test@test.com",
firstName: "Tester",
lastName: "Testersson",
join: false,
membershipNo: "12345678901234",
phoneNumber: "+46700000000",
zipCode: "",
}
const breakfastPackages = [
{
code: BreakfastPackageEnum.REGULAR_BREAKFAST,
description: "Breakfast with reservation",
localPrice: {
currency: "SEK",
price: "99",
totalPrice: "99",
},
requestedPrice: {
currency: "EUR",
price: "9",
totalPrice: "9",
},
packageType: PackageTypeEnum.BreakfastAdult as const,
},
]
function Wrapper({ children }: PropsWithChildren) { function Wrapper({ children }: PropsWithChildren) {
return ( return (
<EnterDetailsProvider <EnterDetailsProvider
bedTypes={bedTypes} bedTypes={[bedType.king, bedType.queen]}
booking={booking} booking={booking}
showBreakfastStep={true} showBreakfastStep={true}
packages={null} packages={null}
roomRate={{ roomRate={roomRate}
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
currency: "EUR",
pricePerNight: 100,
pricePerStay: 200,
},
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
currency: "EUR",
pricePerNight: 100,
pricePerStay: 200,
},
},
}}
searchParamsStr="" searchParamsStr=""
step={StepEnum.selectBed} step={StepEnum.selectBed}
user={null} user={null}
@@ -154,10 +72,13 @@ describe("Enter Details Store", () => {
test("initialize with correct values from sessionStorage", async () => { test("initialize with correct values from sessionStorage", async () => {
const storage: PersistedState = { const storage: PersistedState = {
bedType: bedTypes[1], bedType: {
breakfast: breakfastPackages[0], roomTypeCode: bedType.queen.value,
description: bedType.queen.description,
},
breakfast: breakfastPackage,
booking, booking,
guest, guest: guestDetailsNonMember,
} }
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage)) window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
@@ -187,7 +108,10 @@ describe("Enter Details Store", () => {
expect(result.current.currentStep).toEqual(StepEnum.selectBed) expect(result.current.currentStep).toEqual(StepEnum.selectBed)
await act(async () => { await act(async () => {
result.current.actions.updateBedType(bedTypes[0]) result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
}) })
expect(result.current.isValid[StepEnum.selectBed]).toEqual(true) expect(result.current.isValid[StepEnum.selectBed]).toEqual(true)
@@ -195,7 +119,7 @@ describe("Enter Details Store", () => {
expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast) expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast)
await act(async () => { await act(async () => {
result.current.actions.updateBreakfast(breakfastPackages[0]) result.current.actions.updateBreakfast(breakfastPackage)
}) })
expect(result.current.isValid[StepEnum.breakfast]).toEqual(true) expect(result.current.isValid[StepEnum.breakfast]).toEqual(true)
@@ -203,7 +127,7 @@ describe("Enter Details Store", () => {
expect(window.location.pathname.slice(1)).toBe(StepEnum.details) expect(window.location.pathname.slice(1)).toBe(StepEnum.details)
await act(async () => { await act(async () => {
result.current.actions.updateDetails(guest) result.current.actions.updateDetails(guestDetailsNonMember)
}) })
expect(result.current.isValid[StepEnum.details]).toEqual(true) expect(result.current.isValid[StepEnum.details]).toEqual(true)

View File

@@ -1,12 +1,11 @@
import { z } from "zod" import { type z } from "zod"
import { import type { breakfastFormSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import type {
breakfastPackageSchema, breakfastPackageSchema,
breakfastPackagesSchema, breakfastPackagesSchema,
} from "@/server/routers/hotels/output" } from "@/server/routers/hotels/output"
import { breakfastFormSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
export interface BreakfastFormSchema export interface BreakfastFormSchema
extends z.output<typeof breakfastFormSchema> {} extends z.output<typeof breakfastFormSchema> {}

View File

@@ -1,12 +1,11 @@
import { z } from "zod" import type { z } from "zod"
import { import type { SafeUser } from "@/types/user"
import type {
guestDetailsSchema, guestDetailsSchema,
signedInDetailsSchema, signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema" } from "@/components/HotelReservation/EnterDetails/Details/schema"
import type { SafeUser } from "@/types/user"
export type DetailsSchema = z.output<typeof guestDetailsSchema> export type DetailsSchema = z.output<typeof guestDetailsSchema>
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema> export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>

View File

@@ -1,7 +1,15 @@
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { Packages } from "@/types/requests/packages" import type { Packages } from "@/types/requests/packages"
import type { DetailsState, Price } from "@/types/stores/enter-details" import type {
DetailsState,
Price,
RoomPrice,
} from "@/types/stores/enter-details"
import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability" import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability"
import type { Child } from "./selectRate/selectRate" import type { BedTypeSchema } from "./enterDetails/bedType"
import type { BreakfastPackage } from "./enterDetails/breakfast"
import type { DetailsSchema } from "./enterDetails/details"
import type { Child, SelectRateSearchParams } from "./selectRate/selectRate"
export type RoomsData = Pick<DetailsState, "roomPrice"> & export type RoomsData = Pick<DetailsState, "roomPrice"> &
Pick<RoomAvailability, "cancellationText" | "rateDetails"> & Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
@@ -17,3 +25,26 @@ export interface SummaryProps
isMember: boolean isMember: boolean
breakfastIncluded: boolean breakfastIncluded: boolean
} }
export interface SummaryUIProps {
booking: SelectRateSearchParams
rooms: {
adults: number
childrenInRoom: Child[] | undefined
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
guest: DetailsSchema
roomRate: DetailsProviderProps["roomRate"]
roomPrice: RoomPrice
roomType: string
rateDetails: string[] | undefined
cancellationText: string
}[]
isMember: boolean
breakfastIncluded: boolean
packages: Packages | null
totalPrice: Price
vat: number
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
}