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 SummaryUI from "./UI"
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) {
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 (
<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>
)
}

View File

@@ -49,11 +49,15 @@
opacity: 1;
}
.content,
.priceDetailsButton {
overflow: hidden;
}
.content {
max-height: 50dvh;
overflow-y: auto;
}
@media screen and (min-width: 768px) {
.bottomSheet {
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) {
return {
join: state.guest.join,
membershipNo: state.guest.membershipNo,
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 MobileSummary(props: SummaryProps) {
const { join, membershipNo } = useEnterDetailsStore(storeSelector)
const showPromo = !props.isMember && !join && !membershipNo
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,
},
]
const showPromo = !props.isMember && !guest.join && !guest.membershipNo
return (
<div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null}
<SummaryBottomSheet>
<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>
</SummaryBottomSheet>
</div>

View File

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

View File

@@ -1,9 +1,9 @@
"use client"
import React from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import {
@@ -26,84 +26,22 @@ import PriceDetailsTable from "../PriceDetailsTable"
import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/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,
}
}
import type { SummaryUIProps } from "@/types/components/hotelReservation/summary"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
export default function SummaryUI({
cancellationText,
booking,
rooms,
packages,
totalPrice,
isMember,
rateDetails,
roomType,
breakfastIncluded,
}: SummaryProps) {
toggleSummaryOpen,
togglePriceDetailsModalOpen,
}: SummaryUIProps) {
const intl = useIntl()
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 nights = intl.formatMessage(
@@ -123,22 +61,24 @@ export default function SummaryUI({
}
}
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (children?.length) {
const childrenMsg = intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: children.length }
)
guestsParts.push(childrenMsg)
function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) {
return roomRate.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
}
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 (
<section className={styles.summary}>
<header className={styles.header}>
@@ -160,171 +100,255 @@ export default function SummaryUI({
</Button>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
roomPrice.perStay.local.price,
roomPrice.perStay.local.currency
)}
</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>
{rooms.map((room, idx) => {
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
}
title={cancellationText}
>
<div className={styles.terms}>
{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>
{packages
? packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<Body color="uiTextHighContrast">
{roomPackage.description}
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 isFirstRoomMember = roomNumber === 1 && isMember
const showMemberPrice =
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
memberPrice
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
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>
</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">
{intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: children.length }
)}
{guestsParts.join(", ")}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, breakfast.localPrice.currency)}
</Body>
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</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>
) : null}
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
{packages
? packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<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.entry}>
<div>
@@ -334,7 +358,6 @@ export default function SummaryUI({
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Modal
title={intl.formatMessage({ id: "Price details" })}
trigger={
@@ -350,11 +373,12 @@ export default function SummaryUI({
</Button>
}
>
<PriceDetailsTable roomType={roomType} />
{/* // TODO: all rooms needs to be passed to PriceDetails */}
<PriceDetailsTable roomType={rooms[0].roomType} />
</Modal>
</div>
<div>
<Body textTransform="bold">
<Body textTransform="bold" data-testid="total-price">
{formatPrice(
intl,
totalPrice.local.price,
@@ -379,7 +403,7 @@ export default function SummaryUI({
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{!showMemberPrice && memberPrice ? (
{showSignupPromo && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>

View File

@@ -39,6 +39,7 @@
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.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)
return memberPrice ? (
<div className={styles.memberDiscountBannerDesktop}>
<div
className={styles.memberDiscountBannerDesktop}
data-testid="signup-promo-desktop"
>
{badgeContent && <span className={styles.badge}>{badgeContent}</span>}
<Footnote color="burgundy">
{intl.formatMessage<React.ReactNode>(

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,19 @@ import { describe, expect, test } from "@jest/globals"
import { act, renderHook } from "@testing-library/react"
import { type PropsWithChildren } from "react"
import { BedTypeEnum } from "@/constants/booking"
import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsNonMember,
roomRate,
} from "@/__mocks__/hotelReservation"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { detailsStorageName, useEnterDetailsStore } from "."
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { PackageTypeEnum } from "@/types/enums/packages"
import { StepEnum } from "@/types/enums/step"
import type { PersistedState } from "@/types/stores/enter-details"
@@ -27,100 +31,14 @@ jest.mock("@/lib/api", () => ({
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) {
return (
<EnterDetailsProvider
bedTypes={bedTypes}
bedTypes={[bedType.king, bedType.queen]}
booking={booking}
showBreakfastStep={true}
packages={null}
roomRate={{
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
currency: "EUR",
pricePerNight: 100,
pricePerStay: 200,
},
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
currency: "EUR",
pricePerNight: 100,
pricePerStay: 200,
},
},
}}
roomRate={roomRate}
searchParamsStr=""
step={StepEnum.selectBed}
user={null}
@@ -154,10 +72,13 @@ describe("Enter Details Store", () => {
test("initialize with correct values from sessionStorage", async () => {
const storage: PersistedState = {
bedType: bedTypes[1],
breakfast: breakfastPackages[0],
bedType: {
roomTypeCode: bedType.queen.value,
description: bedType.queen.description,
},
breakfast: breakfastPackage,
booking,
guest,
guest: guestDetailsNonMember,
}
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
@@ -187,7 +108,10 @@ describe("Enter Details Store", () => {
expect(result.current.currentStep).toEqual(StepEnum.selectBed)
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)
@@ -195,7 +119,7 @@ describe("Enter Details Store", () => {
expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackages[0])
result.current.actions.updateBreakfast(breakfastPackage)
})
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)
await act(async () => {
result.current.actions.updateDetails(guest)
result.current.actions.updateDetails(guestDetailsNonMember)
})
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,
breakfastPackagesSchema,
} from "@/server/routers/hotels/output"
import { breakfastFormSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
export interface 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,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import type { SafeUser } from "@/types/user"
export type DetailsSchema = z.output<typeof guestDetailsSchema>
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 { 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 { 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"> &
Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
@@ -17,3 +25,26 @@ export interface SummaryProps
isMember: 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
}