feat: add multiroom signup

This commit is contained in:
Simon Emanuelsson
2025-02-17 15:10:48 +01:00
parent 95917e5e4f
commit 92c5566c59
78 changed files with 2035 additions and 1545 deletions

View File

@@ -9,48 +9,33 @@ import {
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Multiroom from "@/components/HotelReservation/EnterDetails/Room/Multiroom"
import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url"
import styles from "./page.module.css"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params"
import type { Packages } from "@/types/requests/packages"
export interface RoomData {
bedTypes?: BedTypeSelection[]
mustBeGuaranteed?: boolean
breakfastIncluded?: boolean
packages: Packages | null
cancellationText: string
rateDetails: string[]
roomType: string
roomRate: RoomRate
}
import type { Room } from "@/types/providers/details/room"
export default async function DetailsPage({
params: { lang },
searchParams,
}: PageArgs<LangParams, SelectRateSearchParams>) {
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
selectRoomParams.delete("modifyRateIndex")
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
if ("modifyRateIndex" in booking) {
delete booking.modifyRateIndex
}
void getProfileSafely()
@@ -61,7 +46,7 @@ export default async function DetailsPage({
toDate: booking.toDate,
}
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const roomsData: RoomData[] = []
const rooms: Room[] = []
for (let room of booking.rooms) {
const childrenAsString =
@@ -92,21 +77,22 @@ export default async function DetailsPage({
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput //
selectedRoomAvailabilityInput
)
if (!roomAvailability) {
continue // TODO: handle no room availability
}
roomsData.push({
rooms.push({
bedTypes: roomAvailability.bedTypes,
packages,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
breakfastIncluded: roomAvailability.breakfastIncluded,
cancellationText: roomAvailability.cancellationText,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
packages,
rateDetails: roomAvailability.rateDetails ?? [],
roomType: roomAvailability.selectedRoom.roomType,
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
roomRate: {
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
@@ -114,7 +100,7 @@ export default async function DetailsPage({
})
}
const isCardOnlyPayment = roomsData.some((room) => room?.mustBeGuaranteed)
const isCardOnlyPayment = rooms.some((room) => room?.mustBeGuaranteed)
const hotelData = await getHotel({
hotelId: booking.hotelId,
isCardOnlyPayment,
@@ -123,13 +109,13 @@ export default async function DetailsPage({
const user = await getProfileSafely()
// const userTrackingData = await getUserTracking()
if (!hotelData || !roomsData) {
if (!hotelData || !rooms) {
return notFound()
}
// const arrivalDate = new Date(booking.fromDate)
// const departureDate = new Date(booking.toDate)
const hotelAttributes = hotelData.hotel
const { hotel } = hotelData
// TODO: add tracking
// const initialHotelsTrackingData: TrackingSDKHotelInfo = {
@@ -147,111 +133,49 @@ export default async function DetailsPage({
// leadTime: differenceInCalendarDays(arrivalDate, new Date()),
// searchType: "hotel",
// bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
// country: hotelAttributes?.address.country,
// hotelID: hotelAttributes?.operaId,
// region: hotelAttributes?.address.city,
// country: hotel?.address.country,
// hotelID: hotel?.operaId,
// region: hotel?.address.city,
// }
const showBreakfastStep = Boolean(
breakfastPackages?.length && !roomsData[0]?.breakfastIncluded
)
const firstRoom = rooms[0]
const multirooms = rooms.slice(1)
return (
<EnterDetailsProvider
booking={booking}
showBreakfastStep={showBreakfastStep}
roomsData={roomsData}
breakfastPackages={breakfastPackages}
rooms={rooms}
searchParamsStr={selectRoomParams.toString()}
user={user}
vat={hotelAttributes.vat}
vat={hotel.vat}
>
<main>
<HotelHeader hotelData={hotelData} />
<div className={styles.container}>
<div className={styles.content}>
{roomsData.map((room, idx) => (
<section key={idx}>
{roomsData.length > 1 && (
<header className={styles.header}>
<Title level="h2" as="h4">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Title>
</header>
)}
<SelectedRoom
hotelId={booking.hotelId}
roomType={room.roomType}
roomTypeCode={booking.rooms[idx].roomTypeCode}
rateDescription={room.cancellationText}
roomIndex={idx}
searchParamsStr={selectRoomParams.toString()}
/>
{room.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
roomIndex={idx}
>
<BedType bedTypes={room.bedTypes} roomIndex={idx} />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({
id: "Select breakfast options",
})}
step={StepEnum.breakfast}
roomIndex={idx}
>
<Breakfast packages={breakfastPackages!} roomIndex={idx} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
roomIndex={idx}
>
<Details
user={idx === 0 ? user : null}
memberPrice={{
currency:
room?.roomRate.memberRate?.localPrice.currency ?? "", // TODO: how to handle undefined,
price:
room?.roomRate.memberRate?.localPrice.pricePerNight ??
0, // TODO: how to handle undefined,
}}
roomIndex={idx}
/>
</SectionAccordion>
</section>
<RoomProvider idx={0} room={firstRoom}>
<RoomOne user={user} />
</RoomProvider>
{multirooms.map((room, idx) => (
// Need to start idx from 1 since first room is
// rendered above
<RoomProvider key={idx + 1} idx={idx + 1} room={room}>
<Multiroom />
</RoomProvider>
))}
<Suspense>
<Payment
user={user}
otherPaymentOptions={
hotelAttributes.merchantInformationData
.alternatePaymentOptions
hotel.merchantInformationData.alternatePaymentOptions
}
supportedCards={hotelAttributes.merchantInformationData.cards}
supportedCards={hotel.merchantInformationData.cards}
mustBeGuaranteed={isCardOnlyPayment}
/>
</Suspense>
</div>
<aside className={styles.summary}>
<MobileSummary
isMember={!!user}
breakfastIncluded={roomsData[0]?.breakfastIncluded ?? false}
/>
<DesktopSummary
isMember={!!user}
breakfastIncluded={roomsData[0]?.breakfastIncluded ?? false}
/>
<MobileSummary isMember={!!user} />
<DesktopSummary isMember={!!user} />
</aside>
</div>
</main>

View File

@@ -9,44 +9,36 @@ import {
type BedTypeEnum,
type ExtraBedTypeEnum,
} from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import { useRoomContext } from "@/contexts/Details/Room"
import BedTypeInfo from "./BedTypeInfo"
import { bedTypeFormSchema } from "./schema"
import styles from "./bedOptions.module.css"
import type {
BedTypeFormSchema,
BedTypeProps,
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BedTypeFormSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { IconProps } from "@/types/components/icon"
export default function BedType({
bedTypes,
roomIndex,
}: BedTypeProps & { roomIndex: number }) {
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialBedType = room.bedType?.roomTypeCode
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
export default function BedType() {
const {
actions: { updateBedType },
room: { bedType, bedTypes },
} = useRoomContext()
const initialBedType = bedType?.roomTypeCode
const methods = useForm<BedTypeFormSchema>({
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(bedTypeFormSchema),
reValidateMode: "onChange",
values: initialBedType ? { bedType: initialBedType } : undefined,
})
const onSubmit = useCallback(
(bedTypeRoomCode: BedTypeFormSchema) => {
const matchingRoom = bedTypes.find(
const matchingRoom = bedTypes?.find(
(roomType) => roomType.value === bedTypeRoomCode.bedType
)
if (matchingRoom) {
@@ -60,12 +52,6 @@ export default function BedType({
[bedTypes, updateBedType]
)
useEffect(() => {
if (initialBedType) {
methods.setValue("bedType", initialBedType)
}
}, [initialBedType, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return

View File

@@ -6,28 +6,25 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import BreakfastChoiceCard from "@/components/HotelReservation/EnterDetails/Breakfast/BreakfastChoiceCard"
import Body from "@/components/TempDesignSystem/Text/Body"
import { useRoomContext } from "@/contexts/Details/Room"
import { breakfastFormSchema } from "./schema"
import styles from "./breakfast.module.css"
import type {
BreakfastFormSchema,
BreakfastProps,
} from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { BreakfastFormSchema } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({
packages,
roomIndex,
}: BreakfastProps & { roomIndex: number }) {
export default function Breakfast() {
const intl = useIntl()
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const packages = useEnterDetailsStore((state) => state.breakfastPackages)
const {
actions: { updateBreakfast },
room,
} = useRoomContext()
const breakfastSelection = room?.breakfast
? room.breakfast.code
@@ -35,14 +32,6 @@ export default function Breakfast({
? "false"
: undefined
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
)
const children = useEnterDetailsStore(
(state) => state.booking.rooms[0].childrenInRoom
)
const methods = useForm<BreakfastFormSchema>({
defaultValues: breakfastSelection
? { breakfast: breakfastSelection }
@@ -65,12 +54,6 @@ export default function Breakfast({
[packages, updateBreakfast]
)
useEffect(() => {
if (breakfastSelection) {
methods.setValue("breakfast", breakfastSelection)
}
}, [breakfastSelection, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return
@@ -82,7 +65,7 @@ export default function Breakfast({
return (
<FormProvider {...methods}>
<div className={styles.container}>
{children?.length ? (
{room.childrenInRoom?.length ? (
<Body>
{intl.formatMessage({
id: "Children's breakfast is always free as part of the adult's breakfast.",
@@ -90,7 +73,7 @@ export default function Breakfast({
</Body>
) : null}
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
{packages?.map((pkg) => (
<BreakfastChoiceCard
key={pkg.code}
name="breakfast"
@@ -118,7 +101,7 @@ export default function Breakfast({
title: intl.formatMessage({ id: "No breakfast" }),
price: {
total: 0,
currency: packages[0].localPrice.currency,
currency: packages?.[0].localPrice.currency ?? "",
},
description: intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",

View File

@@ -0,0 +1,73 @@
"use client"
import { useIntl } from "react-intl"
import { CheckIcon } from "@/components/Icons"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/Details/Room"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name = "join",
}: JoinScandicFriendsCardProps) {
const intl = useIntl()
const { room, roomNr } = useRoomContext()
if (!room.roomRate.memberRate) {
return null
}
const list = [
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
{ title: intl.formatMessage({ id: "Join at no cost" }) },
]
const saveOnJoiningLabel = intl.formatMessage(
{
id: "Pay the member price of {amount} for Room {roomNr}",
},
{
amount: formatPrice(
intl,
room.roomRate.memberRate.localPrice.pricePerStay,
room.roomRate.memberRate.localPrice.currency
),
roomNr,
}
)
return (
<div className={styles.cardContainer}>
<Checkbox name={name} className={styles.checkBox}>
<div>
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
id: "I promise to join Scandic Friends before checking in",
})}
</Caption>
</div>
</Checkbox>
<div className={styles.list}>
{list.map((item) => (
<Caption
key={item.title}
className={styles.listItem}
color="uiTextPlaceholder"
>
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
</Caption>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
.cardContainer {
align-self: flex-start;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
width: min(100%, 600px);
}
.checkBox {
align-self: center;
}
.list {
display: flex;
gap: var(--Spacing-x1);
flex-direction: column;
}
.listItem {
display: flex;
}
@media screen and (min-width: 768px) {
.cardContainer {
gap: var(--Spacing-x1);
}
.list {
flex-direction: row;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,142 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { useRoomContext } from "@/contexts/Details/Room"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import { multiroomDetailsSchema } from "./schema"
import styles from "./details.module.css"
import type { MultiroomDetailsSchema } from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details() {
const intl = useIntl()
const { activeRoom, canProceedToPayment, lastRoom } = useEnterDetailsStore(
(state) => ({
activeRoom: state.activeRoom,
canProceedToPayment: state.canProceedToPayment,
lastRoom: state.lastRoom,
})
)
const {
actions: { updateDetails },
room,
roomNr,
} = useRoomContext()
const initialData = room.guest
const isPaymentNext = activeRoom === lastRoom
const methods = useForm<MultiroomDetailsSchema>({
criteriaMode: "all",
mode: "all",
resolver: zodResolver(multiroomDetailsSchema),
reValidateMode: "onChange",
values: {
countryCode: initialData.countryCode,
email: initialData.email,
firstName: initialData.firstName,
join: initialData.join,
lastName: initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: initialData.phoneNumber,
},
})
const guestIsGoingToJoin = methods.watch("join")
const guestIsMember = methods.watch("membershipNo")
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(updateDetails)}
>
{guestIsMember ? null : <JoinScandicFriendsCard />}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.fullWidth}
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<Input
label={intl.formatMessage({ id: "First name" })}
maxLength={30}
name="firstName"
registerOptions={{ required: true }}
/>
<Input
label={intl.formatMessage({ id: "Last name" })}
maxLength={30}
name="lastName"
registerOptions={{ required: true }}
/>
<CountrySelect
className={styles.fullWidth}
label={intl.formatMessage({ id: "Country" })}
name="countryCode"
registerOptions={{ required: true }}
/>
<Input
className={styles.fullWidth}
label={intl.formatMessage({ id: "Email address" })}
name="email"
registerOptions={{ required: true }}
/>
<Phone
className={styles.fullWidth}
label={intl.formatMessage({ id: "Phone number" })}
name="phoneNumber"
registerOptions={{ required: true }}
/>
{guestIsGoingToJoin ? null : (
<Input
className={styles.fullWidth}
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
)}
</div>
<footer className={styles.footer}>
<Button
disabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
intent="secondary"
size="small"
theme="base"
type="submit"
>
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
import { phoneValidator } from "@/utils/zod/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
const isValidString = (key: string) => stringMatcher.test(key)
export const multiroomDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
join: z.boolean().default(false),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
membershipNo: z
.string()
.optional()
.refine((val) => {
if (val) {
return !val.match(/[^0-9]/g)
}
return true
}, "Only digits are allowed")
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Invalid membership number format"),
})

View File

@@ -10,6 +10,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { useRoomContext } from "@/contexts/Details/Room"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
@@ -18,11 +19,15 @@ import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
memberPrice,
name = "join",
}: JoinScandicFriendsCardProps) {
const lang = useLang()
const intl = useIntl()
const { room } = useRoomContext()
if (!room.roomRate.memberRate) {
return null
}
const list = [
{ title: intl.formatMessage({ id: "Friendly room rates" }) },
@@ -37,8 +42,8 @@ export default function JoinScandicFriendsCard({
{
amount: formatPrice(
intl,
memberPrice?.price ?? 0,
memberPrice?.currency ?? "SEK"
room.roomRate.memberRate.localPrice.pricePerStay,
room.roomRate.memberRate.localPrice.currency
),
}
)
@@ -47,11 +52,9 @@ export default function JoinScandicFriendsCard({
<div className={styles.cardContainer}>
<Checkbox name={name} className={styles.checkBox}>
<div>
{memberPrice ? (
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
) : null}
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
<Caption
type="label"
textTransform="uppercase"

View File

@@ -2,15 +2,13 @@
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { MagicWandIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useRoomContext } from "@/contexts/Details/Room"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./modal.module.css"
@@ -24,7 +22,7 @@ export default function MemberPriceModal({
isOpen: boolean
setIsOpen: Dispatch<SetStateAction<boolean>>
}) {
const room = useEnterDetailsStore(selectRoom)
const { room } = useRoomContext()
const memberRate = room.roomRate.memberRate
const intl = useIntl()

View File

@@ -0,0 +1,30 @@
.form {
display: grid;
gap: var(--Spacing-x3);
}
.container {
display: grid;
gap: var(--Spacing-x2);
width: min(100%, 600px);
}
.fullWidth {
grid-column: 1/-1;
}
.footer {
margin-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.form {
gap: var(--Spacing-x3);
}
.container {
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px);
}
}

View File

@@ -5,16 +5,13 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
} from "@/stores/enter-details/helpers"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { useRoomContext } from "@/contexts/Details/Room"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import MemberPriceModal from "./MemberPriceModal"
@@ -30,24 +27,26 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({
user,
memberPrice,
roomIndex,
}: DetailsProps & { roomIndex: number }) {
export default function Details({ user }: DetailsProps) {
const intl = useIntl()
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
useEnterDetailsStore(selectBookingProgress)
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialData = room.guest
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
const { activeRoom, canProceedToPayment, lastRoom } = useEnterDetailsStore(
(state) => ({
activeRoom: state.activeRoom,
canProceedToPayment: state.canProceedToPayment,
lastRoom: state.lastRoom,
})
)
const {
actions: { updateDetails },
room,
roomNr,
} = useRoomContext()
const initialData = room.guest
const memberRate = room.roomRate.memberRate
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
const isPaymentNext = activeRoom === lastRoom
const methods = useForm<DetailsSchema>({
criteriaMode: "all",
@@ -70,24 +69,22 @@ export default function Details({
const onSubmit = useCallback(
(values: DetailsSchema) => {
if ((values.join || values.membershipNo) && memberPrice && !user) {
if ((values.join || values.membershipNo) && memberRate && !user) {
setIsMemberPriceModalOpen(true)
}
updateDetails(values)
},
[updateDetails, setIsMemberPriceModalOpen, memberPrice, user]
[updateDetails, setIsMemberPriceModalOpen, memberRate, user]
)
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomIndex + 1}`}
id={`${formID}-room-${roomNr}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user ? null : (
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
)}
{user ? null : <JoinScandicFriendsCard />}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
@@ -155,9 +152,9 @@ export default function Details({
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: currentRoomIndex + 2 }
)}
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
<MemberPriceModal

View File

@@ -55,7 +55,6 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
}
export default function PaymentClient({
user,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
@@ -65,17 +64,13 @@ export default function PaymentClient({
const intl = useIntl()
const searchParams = useSearchParams()
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
(state) => {
return {
totalPrice: state.totalPrice,
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
}
}
)
const canProceedToPayment = bookingProgress.canProceedToPayment
const { booking, canProceedToPayment, rooms, totalPrice } =
useEnterDetailsStore((state) => ({
booking: state.booking,
canProceedToPayment: state.canProceedToPayment,
rooms: state.rooms,
totalPrice: state.totalPrice,
}))
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
@@ -120,7 +115,7 @@ export default function PaymentClient({
if (priceChange) {
setPriceChangeData({
oldPrice: rooms[0].roomPrice.perStay.local.price,
oldPrice: rooms[0].room.roomPrice.perStay.local.price,
newPrice: priceChange.totalPrice,
})
} else {
@@ -232,27 +227,27 @@ export default function PaymentClient({
hotelId,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room, idx) => ({
rooms: rooms.map(({ room }, idx) => ({
adults: room.adults,
childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
(user || room.guest.join || room.guest.membershipNo) &&
(room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
? booking.rooms[idx].counterRateCode
: booking.rooms[idx].rateCode,
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
becomeMember: room.guest.join,
countryCode: room.guest.countryCode,
dateOfBirth: room.guest.dateOfBirth,
email: room.guest.email,
firstName: room.guest.firstName,
lastName: room.guest.lastName,
email: room.guest.email,
phoneNumber: room.guest.phoneNumber,
countryCode: room.guest.countryCode,
membershipNumber: room.guest.membershipNo,
becomeMember: room.guest.join,
dateOfBirth: room.guest.dateOfBirth,
phoneNumber: room.guest.phoneNumber,
postalCode: room.guest.zipCode,
},
packages: {
@@ -301,7 +296,6 @@ export default function PaymentClient({
fromDate,
toDate,
rooms,
user,
booking,
]
)

View File

@@ -5,7 +5,6 @@ import PaymentClient from "./PaymentClient"
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
export default async function Payment({
user,
otherPaymentOptions,
mustBeGuaranteed,
supportedCards,
@@ -16,7 +15,6 @@ export default async function Payment({
return (
<PaymentClient
user={user}
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}

View File

@@ -0,0 +1,3 @@
.header {
padding-bottom: var(--Spacing-x3);
}

View File

@@ -0,0 +1,5 @@
import styles from "./header.module.css"
export default function Header({ children }: React.PropsWithChildren) {
return <header className={styles.header}>{children}</header>
}

View File

@@ -0,0 +1,66 @@
"use client"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details/Multiroom"
import Header from "@/components/HotelReservation/EnterDetails/Room/Header"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useRoomContext } from "@/contexts/Details/Room"
import { StepEnum } from "@/types/enums/step"
export default function Multiroom() {
const intl = useIntl()
const { room, roomNr } = useRoomContext()
const breakfastPackages = useEnterDetailsStore(
(state) => state.breakfastPackages
)
const showBreakfastStep =
!room.breakfastIncluded && !!breakfastPackages?.length
return (
<section>
<Header>
<Title level="h2" as="h4">
{`${intl.formatMessage({ id: "Room" })} ${roomNr}`}
</Title>
</Header>
<SelectedRoom />
{room.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
>
<BedType />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({
id: "Select breakfast options",
})}
step={StepEnum.breakfast}
>
<Breakfast />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
>
<Details />
</SectionAccordion>
</section>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details/RoomOne"
import Header from "@/components/HotelReservation/EnterDetails/Room/Header"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useRoomContext } from "@/contexts/Details/Room"
import { StepEnum } from "@/types/enums/step"
import type { SafeUser } from "@/types/user"
export default function RoomOne({ user }: { user: SafeUser }) {
const intl = useIntl()
const { room } = useRoomContext()
const { breakfastPackages, rooms } = useEnterDetailsStore((state) => ({
breakfastPackages: state.breakfastPackages,
rooms: state.rooms,
}))
const isMultiroom = rooms.length > 1
const showBreakfastStep =
!room.breakfastIncluded && !!breakfastPackages?.length
return (
<section>
{isMultiroom ? (
<Header>
<Title level="h2" as="h4">
{`${intl.formatMessage({ id: "Room" })} 1`}
</Title>
</Header>
) : null}
<SelectedRoom />
{room.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
>
<BedType />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({
id: "Select breakfast options",
})}
step={StepEnum.breakfast}
>
<Breakfast />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
>
<Details user={user} />
</SectionAccordion>
</section>
)
}

View File

@@ -2,16 +2,10 @@
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
selectRoomStatus,
} from "@/stores/enter-details/helpers"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Details/Room"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./sectionAccordion.module.css"
@@ -24,32 +18,27 @@ export default function SectionAccordion({
header,
label,
step,
roomIndex,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const roomStatus = useEnterDetailsStore((state) =>
selectRoomStatus(state, roomIndex)
)
const stickyPosition = useStickyPosition({})
const setStep = useEnterDetailsStore((state) => state.actions.setStep)
const { bedType, breakfast } = useEnterDetailsStore((state) =>
selectRoom(state, roomIndex)
)
const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) =>
selectBookingProgress(state)
)
const {
actions: { setStep },
currentStep,
isActiveRoom,
room: { bedType, breakfast },
steps,
} = useRoomContext()
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = roomStatus.steps[step]?.isValid ?? false
const isValid = steps[step]?.isValid ?? false
const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
// useScrollToActiveSection(step, steps, currentStep === step)
useEffect(() => {
if (step === StepEnum.selectBed && bedType) {
@@ -72,14 +61,13 @@ export default function SectionAccordion({
const accordionRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const shouldBeOpen =
roomStatus.currentStep === step && currentRoomIndex === roomIndex
const shouldBeOpen = currentStep === step && isActiveRoom
setIsOpen(shouldBeOpen)
// Scroll to this section when it is opened, but wait for the accordion animations to
// finish, else the height calculations will not be correct and the scroll position
// will be off.
// Scroll to this section when it is opened,
// but wait for the accordion animations to finish,
// else the height calculations will not be correct and
// the scroll position will be off.
if (shouldBeOpen) {
const handleTransitionEnd = () => {
if (accordionRef.current) {
@@ -103,26 +91,15 @@ export default function SectionAccordion({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
}, [currentStep, isActiveRoom, setIsOpen, step])
function onModify() {
setStep(step, roomIndex)
function goToStep() {
setStep(step)
}
function close() {
setIsOpen(false)
const nextRoom = roomStatuses.find((room) => !room.isComplete)
const nextStep = nextRoom
? Object.values(nextRoom.steps).find((step) => !step.isValid)?.step
: null
if (nextRoom !== undefined && nextStep !== undefined) {
setStep(nextStep, roomStatuses.indexOf(nextRoom))
} else {
// Time for payment, collapse any open step
setStep(null)
}
goToStep()
}
const textColor =
@@ -143,7 +120,7 @@ export default function SectionAccordion({
</div>
<header className={styles.header}>
<button
onClick={isOpen ? close : onModify}
onClick={isOpen ? close : goToStep}
disabled={!isComplete}
className={styles.modifyButton}
>

View File

@@ -5,38 +5,36 @@ import { useTransition } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Details/Room"
import useLang from "@/hooks/useLang"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./selectedRoom.module.css"
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
export default function SelectedRoom({
hotelId,
roomType,
roomTypeCode,
rateDescription,
roomIndex,
searchParamsStr,
}: SelectedRoomProps) {
export default function SelectedRoom() {
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const [isPending, startTransition] = useTransition()
const { modifyRate } = useRateSelectionStore()
const { room, roomNr } = useRoomContext()
const { hotelId, searchParamsStr } = useEnterDetailsStore((state) => ({
hotelId: state.booking.hotelId,
searchParamsStr: state.searchParamString,
}))
function changeRoom() {
modifyRate(roomIndex)
const searchParams = new URLSearchParams(searchParamsStr)
// rooms are index based, thus need for subtraction
searchParams.set("modifyRateIndex", `${roomNr - 1}`)
startTransition(() => {
router.push(`${selectRate(lang)}?${searchParamsStr}`)
router.push(`${selectRate(lang)}?${searchParams.toString()}`)
})
}
@@ -66,8 +64,8 @@ export default function SelectedRoom({
{intl.formatMessage(
{ id: "{roomType} <rate>{rateDescription}</rate>" },
{
roomType: roomType,
rateDescription,
roomType: room.roomType,
rateDescription: room.cancellationText,
rate: (str) => {
return <span className={styles.rate}>{str}</span>
},
@@ -85,12 +83,12 @@ export default function SelectedRoom({
{intl.formatMessage({ id: "Change room" })}
</Button>
</div>
{roomTypeCode && (
{room.roomTypeCode && (
<div className={styles.details}>
<ToggleSidePeek
hotelId={hotelId}
roomTypeCode={roomTypeCode}
intent="text"
roomTypeCode={room.roomTypeCode}
/>
</div>
)}

View File

@@ -8,7 +8,7 @@ import SummaryUI from "./UI"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function DesktopSummary(props: SummaryProps) {
export default function DesktopSummary({ isMember }: SummaryProps) {
const {
booking,
actions: { toggleSummaryOpen },
@@ -23,8 +23,7 @@ export default function DesktopSummary(props: SummaryProps) {
<SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
isMember={isMember}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}

View File

@@ -11,7 +11,7 @@ import styles from "./mobile.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function MobileSummary(props: SummaryProps) {
export default function MobileSummary({ isMember }: SummaryProps) {
const {
booking,
actions: { toggleSummaryOpen },
@@ -22,10 +22,10 @@ export default function MobileSummary(props: SummaryProps) {
const rooms = useEnterDetailsStore((state) => state.rooms)
const showPromo =
!props.isMember &&
!isMember &&
rooms.length === 1 &&
!rooms[0].guest.join &&
!rooms[0].guest.membershipNo
!rooms[0].room.guest.join &&
!rooms[0].room.guest.membershipNo
return (
<div className={styles.mobileSummary}>
@@ -35,8 +35,7 @@ export default function MobileSummary(props: SummaryProps) {
<SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
isMember={isMember}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}

View File

@@ -1,6 +1,6 @@
"use client"
import React from "react"
import { Fragment } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
@@ -32,7 +32,6 @@ export default function SummaryUI({
rooms,
totalPrice,
isMember,
breakfastIncluded,
vat,
toggleSummaryOpen,
}: EnterDetailsSummaryProps) {
@@ -66,9 +65,11 @@ export default function SummaryUI({
rooms.length === 1 &&
rooms
.slice(0, 1)
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
.some(
(r) => !isMember || !r.room.guest.join || !r.room.guest.membershipNo
)
const memberPrice = getMemberPrice(rooms[0].roomRate)
const memberPrice = getMemberPrice(rooms[0].room.roomRate)
return (
<section className={styles.summary}>
@@ -91,7 +92,7 @@ export default function SummaryUI({
</Button>
</header>
<Divider color="primaryLightSubtle" />
{rooms.map((room, idx) => {
{rooms.map(({ room }, idx) => {
const roomNumber = idx + 1
const adults = room.adults
const childrenInRoom = room.childrenInRoom
@@ -139,7 +140,7 @@ export default function SummaryUI({
}
return (
<React.Fragment key={idx}>
<Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
@@ -272,7 +273,7 @@ export default function SummaryUI({
</Body>
</div>
) : null}
{breakfastIncluded ? (
{room.breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
@@ -309,7 +310,9 @@ export default function SummaryUI({
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(room.breakfast.localPrice.totalPrice),
parseInt(room.breakfast.localPrice.price) *
adults *
diff,
room.breakfast.localPrice.currency
)}
</Body>
@@ -337,7 +340,7 @@ export default function SummaryUI({
) : null}
</div>
<Divider color="primaryLightSubtle" />
</React.Fragment>
</Fragment>
)
})}
<div className={styles.total}>
@@ -353,13 +356,13 @@ export default function SummaryUI({
fromDate={booking.fromDate}
toDate={booking.toDate}
rooms={rooms.map((r) => ({
adults: r.adults,
childrenInRoom: r.childrenInRoom,
roomPrice: r.roomPrice,
roomType: r.roomType,
bedType: r.bedType,
breakfast: r.breakfast,
roomFeatures: r.roomFeatures,
adults: r.room.adults,
bedType: r.room.bedType,
breakfast: r.room.breakfast,
childrenInRoom: r.room.childrenInRoom,
roomFeatures: r.room.roomFeatures,
roomPrice: r.room.roomPrice,
roomType: r.room.roomType,
}))}
totalPrice={totalPrice}
vat={vat}

View File

@@ -20,6 +20,7 @@ import SummaryUI from "./UI"
import type { PropsWithChildren } from "react"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { StepEnum } from "@/types/enums/step"
import type { RoomState } from "@/types/stores/enter-details"
jest.mock("@/lib/api", () => ({
@@ -42,36 +43,78 @@ function createWrapper(intlConfig: IntlConfig) {
const rooms: RoomState[] = [
{
adults: 2,
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
bedType: {
description: bedType.queen.description,
roomTypeCode: bedType.queen.value,
currentStep: StepEnum.selectBed,
isComplete: false,
room: {
adults: 2,
bedType: {
description: bedType.queen.description,
roomTypeCode: bedType.queen.value,
},
bedTypes: [],
breakfast: breakfastPackage,
breakfastIncluded: false,
cancellationText: "Non-refundable",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
guest: guestDetailsNonMember,
rateDetails: [],
roomFeatures: [],
roomPrice: roomPrice,
roomRate: roomRate,
roomType: "Standard",
roomTypeCode: "QS",
},
steps: {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: false,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: false,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: false,
},
},
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
{
adults: 1,
childrenInRoom: [],
bedType: {
description: bedType.king.description,
roomTypeCode: bedType.king.value,
currentStep: StepEnum.selectBed,
isComplete: false,
room: {
adults: 1,
bedType: {
description: bedType.king.description,
roomTypeCode: bedType.king.value,
},
bedTypes: [],
breakfast: undefined,
breakfastIncluded: false,
cancellationText: "Non-refundable",
childrenInRoom: [],
guest: guestDetailsMember,
rateDetails: [],
roomFeatures: [],
roomPrice: roomPrice,
roomRate: roomRate,
roomType: "Standard",
roomTypeCode: "QS",
},
steps: {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: false,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: false,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: false,
},
},
breakfast: undefined,
guest: guestDetailsMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
]
@@ -89,7 +132,6 @@ describe("EnterDetails Summary", () => {
booking={booking}
rooms={rooms.slice(0, 1)}
isMember={false}
breakfastIncluded={false}
totalPrice={{
requested: {
currency: "EUR",
@@ -127,7 +169,6 @@ describe("EnterDetails Summary", () => {
booking={booking}
rooms={rooms}
isMember={false}
breakfastIncluded={false}
totalPrice={{
requested: {
currency: "EUR",

View File

@@ -0,0 +1,37 @@
"use client"
import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({
hotelId,
roomTypeCode,
intent = "textInverted",
title,
}: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onClick={() =>
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
}
theme="base"
size="small"
variant="icon"
intent={intent}
wrapping
>
{title ? title : intl.formatMessage({ id: "See room details" })}
<ChevronRight height="14" />
</Button>
)
}

View File

@@ -19,9 +19,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import ToggleSidePeek from "../../EnterDetails/SelectedRoom/ToggleSidePeek"
import PriceDetailsModal from "../../PriceDetailsModal"
import GuestDetails from "./GuestDetails"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./room.module.css"

View File

@@ -164,7 +164,7 @@ export default function PriceDetailsTable({
)}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.price),
parseInt(room.breakfast.localPrice.price) * room.adults,
room.breakfast.localPrice.currency
)}
/>
@@ -193,7 +193,9 @@ export default function PriceDetailsTable({
})}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.totalPrice),
parseInt(room.breakfast.localPrice.totalPrice) *
room.adults *
diff,
room.breakfast.localPrice.currency
)}
/>

View File

@@ -1,6 +1,6 @@
"use client"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useState, useTransition } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
@@ -39,6 +39,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
searchParams: state.searchParams,
}))
const [isSubmitting, setIsSubmitting] = useState(false)
const intl = useIntl()
const router = useRouter()
const params = new URLSearchParams(searchParams)
@@ -111,6 +112,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSubmitting(true)
startTransition(() => {
router.push(`details?${params}`)
})
@@ -267,7 +269,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
</div>
<Button
className={styles.continueButton}
disabled={!isAllRoomsSelected}
disabled={!isAllRoomsSelected || isSubmitting}
theme="base"
type="submit"
>

View File

@@ -1,5 +1,4 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
@@ -12,19 +11,19 @@ import Chip from "@/components/TempDesignSystem/Chip"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import styles from "./selectedRoomPanel.module.css"
export default function SelectedRoomPanel() {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const { rateDefinitions, roomCategories } = useRatesStore((state) => ({
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const { isUserLoggedIn, rateDefinitions, roomCategories } = useRatesStore(
(state) => ({
isUserLoggedIn: state.isUserLoggedIn,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
})
)
const {
actions: { modifyRate },
isMainRoom,

View File

@@ -6,7 +6,7 @@ import { useRatesStore } from "@/stores/select-rate"
import { ChevronUpIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import SelectedRoomPanel from "./SelectedRoomPanel"
import { roomSelectionPanelVariants } from "./variants"

View File

@@ -1,14 +1,13 @@
import { useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { calculatePricesPerNight } from "./utils"
@@ -24,8 +23,7 @@ export default function PriceList({
}: PriceListProps) {
const intl = useIntl()
const { isMainRoom } = useRoomContext()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
publicPrice
@@ -166,20 +164,20 @@ export default function PriceList({
<Caption color="uiTextMediumContrast">
{isUserLoggedIn
? intl.formatMessage(
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
: intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
</Caption>
</dd>
</div>

View File

@@ -1,15 +1,15 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import PriceTable from "./PriceList"
@@ -30,8 +30,7 @@ export default function FlexibilityOption({
rateTitle,
}: FlexibilityOptionProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
const {
actions: { selectRate },
isMainRoom,

View File

@@ -1,7 +1,6 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { createElement } from "react"
import { useIntl } from "react-intl"
@@ -15,8 +14,7 @@ import ImageGallery from "@/components/ImageGallery"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Room"
import { isValidClientSession } from "@/utils/clientSession"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { cardVariants } from "./cardVariants"
@@ -71,8 +69,6 @@ function getBreakfastMessage(
}
export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const intl = useIntl()
const lessThanFiveRoomsLeft =
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
@@ -83,12 +79,14 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const {
hotelId,
hotelType,
isUserLoggedIn,
petRoomPackage,
rateDefinitions,
roomCategories,
} = useRatesStore((state) => ({
hotelId: state.booking.hotelId,
hotelType: state.hotelType,
isUserLoggedIn: state.isUserLoggedIn,
petRoomPackage: state.petRoomPackage,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
@@ -362,7 +360,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
product.productType.member?.rateCode !== undefined)
return (
<FlexibilityOption
key={product.productType.public.rateCode}
key={rate.title}
features={roomConfiguration.features}
isSelected={
isSelectedRateCode &&

View File

@@ -9,7 +9,7 @@ import { useIntl } from "react-intl"
import { useRatesStore } from "@/stores/select-rate"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/Room"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import styles from "./roomFilter.module.css"

View File

@@ -7,7 +7,7 @@ import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import BookingCodeFilter from "@/components/HotelReservation/SelectHotel/BookingCodeFilter"
import Alert from "@/components/TempDesignSystem/Alert"
import { useRoomContext } from "@/contexts/Room"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import RoomCard from "./RoomCard"

View File

@@ -3,7 +3,7 @@ import { useEffect } from "react"
import { useRatesStore } from "@/stores/select-rate"
import RoomProvider from "@/providers/RoomProvider"
import RoomProvider from "@/providers/SelectRate/RoomProvider"
import { trackLowestRoomPrice } from "@/utils/tracking"
import MultiRoomWrapper from "./MultiRoomWrapper"

View File

@@ -1,11 +1,8 @@
"use client"
import { useSession } from "next-auth/react"
import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang"
import RatesProvider from "@/providers/RatesProvider"
import { isValidClientSession } from "@/utils/clientSession"
import { useHotelPackages, useRoomsAvailability } from "../utils"
import RateSummary from "./RateSummary"
@@ -19,12 +16,11 @@ export function RoomsContainer({
booking,
childArray,
fromDate,
hotelId,
hotelData,
hotelId,
isUserLoggedIn,
toDate,
}: RoomsContainerProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const lang = useLang()
const fromDateString = dt(fromDate).format("YYYY-MM-DD")

View File

@@ -7,12 +7,14 @@ import { getHotel } from "@/lib/trpc/memoizedRequests"
import { getValidDates } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/getValidDates"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import { auth } from "@/auth"
import HotelInfoCard, {
HotelInfoCardSkeleton,
} from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext"
import { isValidSession } from "@/utils/session"
import { convertSearchParamsToObj } from "@/utils/url"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
@@ -47,6 +49,9 @@ export default async function SelectRatePage({
language: params.lang,
})
const session = await auth()
const isUserLoggedIn = isValidSession(session)
const arrivalDate = fromDate.toDate()
const departureDate = toDate.toDate()
@@ -92,12 +97,13 @@ export default async function SelectRatePage({
</Suspense>
<RoomsContainer
hotelData={hotelData}
adultArray={adultsInRoom}
booking={booking}
childArray={childrenInRoom}
fromDate={arrivalDate}
hotelData={hotelData}
hotelId={hotelId}
isUserLoggedIn={isUserLoggedIn}
toDate={departureDate}
/>

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from "react"
import type { RoomContextValue } from "@/types/contexts/details/room"
export const RoomContext = createContext<RoomContextValue | null>(null)
export function useRoomContext() {
const ctx = useContext(RoomContext)
if (!ctx) {
throw new Error("Missing context value [RoomContext]")
}
return ctx
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext } from "react"
import type { RoomContextValue } from "@/types/contexts/room"
import type { RoomContextValue } from "@/types/contexts/select-rate/room"
export const RoomContext = createContext<RoomContextValue | null>(null)

View File

@@ -201,6 +201,7 @@
"Download the Scandic app": "Download Scandic-appen",
"Driving directions": "Kørselsanvisning",
"Earn & spend points": "Få medlemsfordele og tilbud",
"Earn bonus nights & points": "Optjen bonusnætter og point",
"Edit": "Redigere",
"Edit profile": "Rediger profil",
"Edit your personal details": "Edit your personal details",
@@ -261,6 +262,7 @@
"Get hotel directions": "Få hotel retninger",
"Get inspired": "Bliv inspireret",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Få medlemsfordele og tilbud",
"Get the member price: {amount}": "Betal kun {amount}",
"Go back": "Go back",
"Go back to edit": "Gå tilbage til redigering",
@@ -299,6 +301,7 @@
"Hurry up and use them before they expire!": "Skynd dig og brug dem, før de udløber!",
"I accept": "Jeg accepterer",
"I accept the terms and conditions": "Jeg accepterer vilkårene",
"I promise to join Scandic Friends before checking in": "Jeg lover at tilmelde mig Scandic Friends, før jeg tjekker ind",
"I would like to get my booking confirmation via sms": "Jeg vil gerne få min booking bekræftelse via SMS",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "Hvis ikke, så gå tilbage og gør det, før du lukker dette. Når du lukker dette, vil din fordel blive ugyldig og fjernet fra Mine fordele.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -319,6 +322,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "Det ser ud til, at ingen hoteller matcher dine filtre. Prøv at justere din søgning for at finde det perfekte ophold.",
"Jacuzzi": "Jacuzzi",
"Join Scandic Friends": "Tilmeld dig Scandic Friends",
"Join at no cost": "Tilmeld dig uden omkostninger",
"Join for free": "Tilmeld dig uden omkostninger",
"Join now": "Tilmeld dig nu",
"Join or log in while booking for member pricing.": "Tilmeld dig eller log ind under booking for medlemspris.",
@@ -463,6 +467,7 @@
"Pay now": "Betal nu",
"Pay with card": "Betal med kort",
"Pay with points": "Betal med point",
"Pay the member price of {amount} for Room {roomNr}": "Betal medlemsprisen på {amount} til værelse {roomNr}",
"Payment": "Betaling",
"Payment Guarantee": "Garanti betaling",
"Payment details": "Payment details",

View File

@@ -202,6 +202,7 @@
"Download the Scandic app": "Laden Sie die Scandic-App herunter",
"Driving directions": "Anfahrtsbeschreibung",
"Earn & spend points": "Holen Sie sich Vorteile und Angebote für Mitglieder",
"Earn bonus nights & points": "Sammeln Sie Bonusnächte und -punkte",
"Edit": "Bearbeiten",
"Edit profile": "Profil bearbeiten",
"Edit your personal details": "Edit your personal details",
@@ -262,6 +263,7 @@
"Get hotel directions": "Hotel Richtungen",
"Get inspired": "Lassen Sie sich inspieren",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Holen Sie sich Vorteile und Angebote für Mitglieder",
"Get the member price: {amount}": "Nur bezahlen {amount}",
"Go back": "Go back",
"Go back to edit": "Zurück zum Bearbeiten",
@@ -300,6 +302,7 @@
"Hurry up and use them before they expire!": "Beeilen Sie sich und nutzen Sie sie, bevor sie ablaufen!",
"I accept": "Ich akzeptiere",
"I accept the terms and conditions": "Ich akzeptiere die Geschäftsbedingungen",
"I promise to join Scandic Friends before checking in": "Ich verspreche, Scandic Friends beizutreten, bevor ich einchecke",
"I would like to get my booking confirmation via sms": "Ich möchte meine Buchungsbestätigung per SMS erhalten",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "Wenn nicht, gehen Sie bitte zurück und tun Sie dies, bevor Sie dies schließen. Sobald Sie dies schließen, verfällt Ihr Vorteil und wird aus „Meine Vorteile“ entfernt.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -320,6 +323,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "Es scheint, dass keine Hotels Ihren Filtern entsprechen. Versuchen Sie, Ihre Suche anzupassen, um den perfekten Aufenthalt zu finden.",
"Jacuzzi": "Whirlpool",
"Join Scandic Friends": "Treten Sie Scandic Friends bei",
"Join at no cost": "Kostenlos beitreten",
"Join for free": "Kostenlos beitreten",
"Join now": "Mitglied werden",
"Join or log in while booking for member pricing.": "Treten Sie Scandic Friends bei oder loggen Sie sich ein, um den Mitgliederpreis zu erhalten.",
@@ -465,6 +469,7 @@
"Pay now": "Jetzt bezahlen",
"Pay with Card": "Mit Karte bezahlen",
"Pay with points": "Mit Punkten bezahlen",
"Pay the member price of {amount} for Room {roomNr}": "Zahlen Sie den Mitgliedspreis von {amount} für Zimmer {roomNr}",
"Payment": "Zahlung",
"Payment Guarantee": "Zahlungsgarantie",
"Payment details": "Payment details",

View File

@@ -203,6 +203,7 @@
"Download the Scandic app": "Download the Scandic app",
"Driving directions": "Driving directions",
"Earn & spend points": "Earn & spend points",
"Earn bonus nights & points": "Earn bonus nights & points",
"Edit": "Edit",
"Edit profile": "Edit profile",
"Edit your personal details": "Edit your personal details",
@@ -263,6 +264,7 @@
"Get hotel directions": "Get hotel directions",
"Get inspired": "Get inspired",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Get member benefits & offers",
"Get the member price: {amount}": "Get the member price: {amount}",
"Go back": "Go back",
"Go back to edit": "Go back to edit",
@@ -301,6 +303,7 @@
"Hurry up and use them before they expire!": "Hurry up and use them before they expire!",
"I accept": "I accept",
"I accept the terms and conditions": "I accept the terms and conditions",
"I promise to join Scandic Friends before checking in": "I promise to join Scandic Friends before checking in",
"I would like to get my booking confirmation via sms": "I would like to get my booking confirmation via sms",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -321,6 +324,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
"Jacuzzi": "Jacuzzi",
"Join Scandic Friends": "Join Scandic Friends",
"Join at no cost": "Join at no cost",
"Join for free": "Join for free",
"Join now": "Join now",
"Join or log in while booking for member pricing.": "Join or log in while booking for member pricing.",
@@ -464,6 +468,7 @@
"Password": "Password",
"Pay later": "Pay later",
"Pay now": "Pay now",
"Pay the member price of {amount} for Room {roomNr}": "Pay the member price of {amount} for Room {roomNr}",
"Pay with Card": "Pay with Card",
"Pay with points": "Pay with points",
"Payment": "Payment",
@@ -510,6 +515,7 @@
"Print confirmation": "Print confirmation",
"Proceed to login": "Proceed to login",
"Proceed to payment": "Proceed to payment",
"Proceed to payment method": "Proceed to payment method",
"Promo code": "Promo code",
"Provide a payment card in the next step": "Provide a payment card in the next step",
"Public price from": "Public price from",

View File

@@ -201,6 +201,7 @@
"Download the Scandic app": "Lataa Scandic-sovellus",
"Driving directions": "Ajo-ohjeet",
"Earn & spend points": "Hanki jäsenetuja ja -tarjouksia",
"Earn bonus nights & points": "Ansaitse bonusöitä ja pisteitä",
"Edit": "Muokata",
"Edit profile": "Muokkaa profiilia",
"Edit your personal details": "Edit your personal details",
@@ -261,6 +262,7 @@
"Get hotel directions": "Hae hotellin suunnat",
"Get inspired": "Inspiroidu",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Hanki jäsenetuja ja -tarjouksia",
"Get the member price: {amount}": "Vain maksaa {amount}",
"Go back": "Go back",
"Go back to edit": "Palaa muokkaamaan",
@@ -299,6 +301,7 @@
"Hurry up and use them before they expire!": "Ole nopea ja käytä ne ennen kuin ne vanhenevat!",
"I accept": "Hyväksyn",
"I accept the terms and conditions": "Hyväksyn käyttöehdot",
"I promise to join Scandic Friends before checking in": "Lupaan liittyä Scandic Friends -ohjelmaan ennen sisäänkirjautumista",
"I would like to get my booking confirmation via sms": "Haluan saada varauksen vahvistuksen SMS-viestillä",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "Jos ei, palaa takaisin ja tee se ennen kuin suljet tämän. Kun suljet tämän, etusi mitätöidään ja poistetaan Omista eduista.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -319,6 +322,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "Näyttää siltä, että mikään hotelli ei vastaa suodattimiasi. Yritä muokata hakuasi löytääksesi täydellisen oleskelun.",
"Jacuzzi": "Poreallas",
"Join Scandic Friends": "Liity jäseneksi",
"Join at no cost": "Liity maksutta",
"Join for free": "Liity maksutta",
"Join now": "Liity jäseneksi",
"Join or log in while booking for member pricing.": "Liity tai kirjaudu sisään, kun varaat jäsenhinnan.",
@@ -464,6 +468,7 @@
"Pay now": "Maksa nyt",
"Pay with Card": "Maksa kortilla",
"Pay with points": "Maksa pisteillä",
"Pay the member price of {amount} for Room {roomNr}": "Maksa jäsenhinta {amount} varten Huone {roomNr}",
"Payment": "Maksu",
"Payment Guarantee": "Varmistusmaksu",
"Payment details": "Payment details",

View File

@@ -200,6 +200,7 @@
"Download the Scandic app": "Last ned Scandic-appen",
"Driving directions": "Veibeskrivelser",
"Earn & spend points": "Få medlemsfordeler og tilbud",
"Earn bonus nights & points": "Tjen bonusnetter og poeng",
"Edit": "Redigere",
"Edit profile": "Rediger profil",
"Edit your personal details": "Edit your personal details",
@@ -260,6 +261,7 @@
"Get hotel directions": "Få hotel retninger",
"Get inspired": "Bli inspirert",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Få medlemsfordeler og tilbud",
"Get the member price: {amount}": "Bare betal {amount}",
"Go back": "Go back",
"Go back to edit": "Gå tilbake til redigering",
@@ -298,6 +300,7 @@
"Hurry up and use them before they expire!": "Skynd deg og bruk dem før de utløper!",
"I accept": "Jeg aksepterer",
"I accept the terms and conditions": "Jeg aksepterer vilkårene",
"I promise to join Scandic Friends before checking in": "Jeg lover å bli med i Scandic Friends før jeg sjekker inn",
"I would like to get my booking confirmation via sms": "Jeg vil gjerne motta bekreftelsen av bestillingen min via sms",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "Hvis ikke, gå tilbake og gjør det før du lukker dette. Når du lukker dette, vil fordelen din bli ugyldig og fjernet fra Mine fordeler.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -318,6 +321,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "Det ser ut til at ingen hoteller samsvarer med filtrene dine. Prøv å justere søket for å finne det perfekte oppholdet.",
"Jacuzzi": "Boblebad",
"Join Scandic Friends": "Bli med i Scandic Friends",
"Join at no cost": "Bli med uten kostnad",
"Join for free": "Bli med uten kostnad",
"Join now": "Bli medlem nå",
"Join or log in while booking for member pricing.": "Bli med eller logg inn under bestilling for medlemspris.",
@@ -461,6 +465,7 @@
"Password": "Passord",
"Pay later": "Betal senere",
"Pay now": "Betal nå",
"Pay the member price of {amount} for Room {roomNr}": "Betal medlemsprisen på {amount} for rom {roomNr}",
"Payment": "Betaling",
"Payment Guarantee": "Garantera betalning",
"Payment details": "Payment details",

View File

@@ -200,6 +200,7 @@
"Download the Scandic app": "Ladda ner Scandic-appen",
"Driving directions": "Vägbeskrivningar",
"Earn & spend points": "Ta del av medlemsförmåner och erbjudanden",
"Earn bonus nights & points": "Tjäna bonusnätter och poäng",
"Edit": "Redigera",
"Edit profile": "Redigera profil",
"Edit your personal details": "Edit your personal details",
@@ -260,6 +261,7 @@
"Get hotel directions": "Hämta vägbeskrivning till hotellet",
"Get inspired": "Bli inspirerad",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
"Get member benefits & offers": "Ta del av medlemsförmåner och erbjudanden",
"Get the member price: {amount}": "Betala endast {amount}",
"Go back": "Go back",
"Go back to edit": "Gå tillbaka till redigeringen",
@@ -298,6 +300,7 @@
"Hurry up and use them before they expire!": "Skynda dig och använd dem innan de går ut!",
"I accept": "Jag accepterar",
"I accept the terms and conditions": "Jag accepterar villkoren",
"I promise to join Scandic Friends before checking in": "Jag lovar att gå med i Scandic Friends innan jag checkar in",
"I would like to get my booking confirmation via sms": "Jag vill få min bokningsbekräftelse via sms",
"If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.": "Om inte, gå tillbaka och gör det innan du stänger detta. När du stänger detta kommer din förmån att ogiltigförklaras och tas bort från Mina förmåner.",
"If you are not redirected automatically, please <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
@@ -318,6 +321,7 @@
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.": "Det verkar som att inga hotell matchar dina filter. Prova att justera din sökning för att hitta den perfekta vistelsen.",
"Jacuzzi": "Jacuzzi",
"Join Scandic Friends": "Gå med i Scandic Friends",
"Join at no cost": "Gå med utan kostnad",
"Join for free": "Gå med utan kostnad",
"Join now": "Gå med nu",
"Join or log in while booking for member pricing.": "Bli medlem eller logga in när du bokar för medlemspriser.",
@@ -461,6 +465,7 @@
"Password": "Lösenord",
"Pay later": "Betala senare",
"Pay now": "Betala nu",
"Pay the member price of {amount} for Room {roomNr}": "Betala medlemspriset på {amount} för rum {roomNr}",
"Payment": "Betalning",
"Payment Guarantee": "Garantera betalning",
"Payment details": "Payment details",

View File

@@ -0,0 +1,39 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { RoomContext } from "@/contexts/Details/Room"
import type { RoomProviderProps } from "@/types/providers/details/room"
export default function RoomProvider({ children, idx }: RoomProviderProps) {
const actions = useEnterDetailsStore((state) => ({
setStep: state.actions.setStep(idx),
updateBedType: state.actions.updateBedType(idx),
updateBreakfast: state.actions.updateBreakfast(idx),
updateDetails: state.actions.updateDetails(idx),
}))
const { activeRoom, currentStep, isComplete, room, steps } =
useEnterDetailsStore((state) => ({
activeRoom: state.activeRoom,
currentStep: state.rooms[idx].currentStep,
isComplete: state.rooms[idx].isComplete,
room: state.rooms[idx].room,
steps: state.rooms[idx].steps,
}))
return (
<RoomContext.Provider
value={{
actions,
currentStep,
isComplete,
isActiveRoom: activeRoom === idx,
room,
roomNr: idx + 1,
steps,
}}
>
{children}
</RoomContext.Provider>
)
}

View File

@@ -1,8 +1,10 @@
"use client"
import { useEffect, useRef } from "react"
import { dt } from "@/lib/dt"
import { createDetailsStore } from "@/stores/enter-details"
import {
calcTotalPrice,
checkIsSameBedTypes,
checkIsSameBooking as checkIsSameBooking,
clearSessionStorage,
@@ -12,15 +14,14 @@ import {
import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { InitialState } from "@/types/stores/enter-details"
export default function EnterDetailsProvider({
booking,
showBreakfastStep,
breakfastPackages,
children,
roomsData,
rooms,
searchParamsStr,
user,
vat,
@@ -29,14 +30,17 @@ export default function EnterDetailsProvider({
if (!storeRef.current) {
const initialData: InitialState = {
booking,
rooms: roomsData
rooms: rooms
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
.map((room) => ({
breakfastIncluded: !!room.breakfastIncluded,
cancellationText: room.cancellationText,
rateDetails: room.rateDetails,
roomFeatures: room.packages,
roomRate: room.roomRate,
roomType: room.roomType,
cancellationText: room.cancellationText,
rateDetails: room.rateDetails,
roomTypeCode: room.roomTypeCode,
bedTypes: room.bedTypes!,
bedType:
room.bedTypes?.length === 1
? {
@@ -48,11 +52,12 @@ export default function EnterDetailsProvider({
vat,
}
if (!showBreakfastStep) {
initialData.breakfast = false
}
storeRef.current = createDetailsStore(initialData, searchParamsStr, user)
storeRef.current = createDetailsStore(
initialData,
searchParamsStr,
user,
breakfastPackages
)
}
useEffect(() => {
@@ -68,26 +73,26 @@ export default function EnterDetailsProvider({
const updatedRooms = storedValues.rooms.map((storedRoom, idx) => {
const currentRoom = booking.rooms[idx]
const roomData = roomsData[idx]
const room = rooms[idx]
if (!storedRoom.bedType) {
if (!storedRoom.room?.bedType) {
return storedRoom
}
const isSameBedTypes = checkIsSameBedTypes(
storedRoom.bedType.roomTypeCode,
storedRoom.room.bedType.roomTypeCode,
currentRoom.roomTypeCode
)
if (isSameBedTypes) {
return storedRoom
}
if (roomData?.bedTypes?.length === 1 && roomData.bedTypes[0]) {
if (room?.bedTypes?.length === 1 && room.bedTypes[0]) {
return {
...storedRoom,
bedType: {
roomTypeCode: roomData.bedTypes[0].value,
description: roomData.bedTypes[0].description,
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
},
}
}
@@ -99,34 +104,20 @@ export default function EnterDetailsProvider({
}
})
const updatedProgress = {
...storedValues.bookingProgress,
roomStatuses: storedValues.bookingProgress.roomStatuses.map(
(status, idx) => {
const hasValidBedType = Boolean(updatedRooms[idx].bedType)
if (hasValidBedType) return status
const canProceedToPayment = updatedRooms.every((room) => room.isComplete)
return {
...status,
steps: {
...status.steps,
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: false,
},
},
currentStep: StepEnum.selectBed,
isComplete: false,
}
}
),
}
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
const currency =
updatedRooms[0].room.roomRate.publicRate.localPrice.currency
const totalPrice = calcTotalPrice(updatedRooms, currency, !!user, nights)
storeRef.current?.setState({
activeRoom: storedValues.activeRoom,
canProceedToPayment,
rooms: updatedRooms,
bookingProgress: updatedProgress,
totalPrice,
})
}, [booking, roomsData])
}, [booking, rooms, user])
return (
<DetailsContext.Provider value={storeRef.current}>

View File

@@ -2,9 +2,9 @@
import { useRatesStore } from "@/stores/select-rate"
import { RoomContext } from "@/contexts/Room"
import { RoomContext } from "@/contexts/SelectRate/Room"
import type { RoomProviderProps } from "@/types/providers/room"
import type { RoomProviderProps } from "@/types/providers/select-rate/room"
export default function RoomProvider({
children,

View File

@@ -511,8 +511,8 @@ export const hotelQueryRouter = router({
adults: adultCount,
...(childArray &&
childArray.length > 0 && {
children: generateChildrenString(childArray),
}),
children: generateChildrenString(childArray),
}),
...(bookingCode && { bookingCode }),
language: apiLang,
}
@@ -755,9 +755,9 @@ export const hotelQueryRouter = router({
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
@@ -1115,9 +1115,9 @@ export const hotelQueryRouter = router({
return hotelData
? {
...hotelData,
url: hotelPage?.url ?? null,
}
...hotelData,
url: hotelPage?.url ?? null,
}
: null
})
)

View File

@@ -0,0 +1,657 @@
// import { describe, expect, test } from "@jest/globals"
// import { act, renderHook, waitFor } from "@testing-library/react"
// import { type PropsWithChildren } from "react"
// import { BedTypeEnum } from "@/constants/booking"
// import { Lang } from "@/constants/languages"
// import {
// bedType,
// booking,
// breakfastPackage,
// guestDetailsMember,
// guestDetailsNonMember,
// roomPrice,
// roomRate,
// } from "@/__mocks__/hotelReservation"
// import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
// import { detailsStorageName, useEnterDetailsStore } from "."
// import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
// import type { BreakfastPackages } from "@/types/components/hotelReservation/enterDetails/breakfast"
// import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
// import { PackageTypeEnum } from "@/types/enums/packages"
// import { StepEnum } from "@/types/enums/step"
// import type { PersistedState } from "@/types/stores/enter-details"
// jest.mock("react", () => ({
// ...jest.requireActual("react"),
// cache: jest.fn(),
// }))
// jest.mock("@/server/utils", () => ({
// toLang: () => Lang.en,
// }))
// jest.mock("@/lib/api", () => ({
// fetchRetry: jest.fn((fn) => fn),
// }))
// interface CreateWrapperParams {
// bedTypes?: BedTypeSelection[]
// bookingParams?: SelectRateSearchParams
// breakfastIncluded?: boolean
// breakfastPackages?: BreakfastPackages | null
// mustBeGuaranteed?: boolean
// }
// function createWrapper(params: Partial<CreateWrapperParams> = {}) {
// const {
// breakfastIncluded = false,
// breakfastPackages = null,
// mustBeGuaranteed = false,
// bookingParams = booking,
// bedTypes = [bedType.king, bedType.queen],
// } = params
// return function Wrapper({ children }: PropsWithChildren) {
// return (
// <EnterDetailsProvider
// booking={bookingParams}
// breakfastPackages={breakfastPackages}
// rooms={[
// {
// bedTypes,
// packages: null,
// mustBeGuaranteed,
// breakfastIncluded,
// cancellationText: "",
// rateDetails: [],
// roomType: "Standard",
// roomTypeCode: "QS",
// roomRate: roomRate,
// },
// {
// bedTypes,
// packages: null,
// mustBeGuaranteed,
// breakfastIncluded,
// cancellationText: "",
// rateDetails: [],
// roomType: "Standard",
// roomTypeCode: "QS",
// roomRate: roomRate,
// },
// ]}
// searchParamsStr=""
// user={null}
// vat={0}
// >
// {children}
// </EnterDetailsProvider>
// )
// }
// }
// describe("Enter Details Store", () => {
// beforeEach(() => {
// window.sessionStorage.clear()
// })
// describe("initial state", () => {
// test("initialize with correct default values", () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// const state = result.current
// expect(state.booking).toEqual(booking)
// // room 1
// const room1 = result.current.rooms[0]
// expect(room1.currentStep).toBe(StepEnum.selectBed)
// expect(room1.room.roomPrice.perNight.local.price).toEqual(
// roomRate.publicRate.localPrice.pricePerNight
// )
// expect(room1.room.bedType).toEqual(undefined)
// expect(Object.values(room1.room.guest).every((value) => value === ""))
// // room 2
// const room2 = result.current.rooms[1]
// expect(room2.currentStep).toBe(null)
// expect(room2.room.roomPrice.perNight.local.price).toEqual(
// room2.room.roomRate.publicRate.localPrice.pricePerNight
// )
// expect(room2.room.bedType).toEqual(undefined)
// expect(Object.values(room2.room.guest).every((value) => value === ""))
// })
// test("initialize with correct values from session storage", () => {
// const storage: PersistedState = {
// activeRoom: 0,
// booking: booking,
// rooms: [
// {
// currentStep: StepEnum.selectBed,
// isComplete: false,
// room: {
// adults: 1,
// bedType: {
// description: bedType.king.description,
// roomTypeCode: bedType.king.value,
// },
// bedTypes: [
// {
// description: bedType.king.description,
// extraBed: undefined,
// size: {
// min: 100,
// max: 120,
// },
// type: BedTypeEnum.King,
// value: bedType.king.value,
// },
// ],
// breakfastIncluded: false,
// breakfast: breakfastPackage,
// cancellationText: "Non-refundable",
// childrenInRoom: [],
// guest: guestDetailsNonMember,
// rateDetails: [],
// roomFeatures: null,
// roomPrice: roomPrice,
// roomRate: roomRate,
// roomType: "Classic Double",
// roomTypeCode: "QS",
// },
// steps: {
// [StepEnum.selectBed]: {
// step: StepEnum.selectBed,
// isValid: true,
// },
// [StepEnum.breakfast]: {
// step: StepEnum.breakfast,
// isValid: true,
// },
// [StepEnum.details]: {
// step: StepEnum.details,
// isValid: true,
// },
// },
// },
// ],
// }
// window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// expect(result.current.booking).toEqual(storage.booking)
// expect(result.current.rooms[0]).toEqual(storage.rooms[0])
// })
// })
// test("add bedtype and proceed to next step", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// let room1 = result.current.rooms[0]
// expect(room1.currentStep).toEqual(StepEnum.selectBed)
// const selectedBedType = {
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// }
// await act(async () => {
// result.current.actions.updateBedType(0)(selectedBedType)
// })
// room1 = result.current.rooms[0]
// expect(room1.steps[StepEnum.selectBed].isValid).toEqual(true)
// expect(room1.room.bedType).toEqual(selectedBedType)
// expect(room1.currentStep).toEqual(StepEnum.breakfast)
// })
// test("complete step and navigate to next step", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// // Room 1
// expect(result.current.activeRoom).toEqual(0)
// let room1 = result.current.rooms[0]
// expect(room1.currentStep).toEqual(StepEnum.selectBed)
// await act(async () => {
// result.current.actions.updateBedType(0)({
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// })
// })
// room1 = result.current.rooms[0]
// expect(room1.steps[StepEnum.selectBed].isValid).toEqual(true)
// expect(room1.currentStep).toEqual(StepEnum.breakfast)
// await act(async () => {
// result.current.actions.updateBreakfast(0)(breakfastPackage)
// })
// room1 = result.current.rooms[0]
// expect(room1.steps[StepEnum.breakfast]?.isValid).toEqual(true)
// expect(room1.currentStep).toEqual(StepEnum.details)
// await act(async () => {
// result.current.actions.updateDetails(0)(guestDetailsNonMember)
// })
// expect(result.current.canProceedToPayment).toBe(false)
// // Room 2
// expect(result.current.activeRoom).toEqual(1)
// let room2 = result.current.rooms[1]
// expect(room2.currentStep).toEqual(StepEnum.selectBed)
// await act(async () => {
// const selectedBedType = {
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// }
// result.current.actions.updateBedType(1)(selectedBedType)
// })
// room2 = result.current.rooms[1]
// expect(room2.steps[StepEnum.selectBed].isValid).toEqual(true)
// expect(room2.currentStep).toEqual(StepEnum.breakfast)
// await act(async () => {
// result.current.actions.updateBreakfast(1)(breakfastPackage)
// })
// room2 = result.current.rooms[1]
// expect(room2.steps[StepEnum.breakfast]?.isValid).toEqual(true)
// expect(room2.currentStep).toEqual(StepEnum.details)
// await act(async () => {
// result.current.actions.updateDetails(1)(guestDetailsNonMember)
// })
// expect(result.current.canProceedToPayment).toBe(true)
// })
// test("all steps needs to be completed before going to next room", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// await act(async () => {
// result.current.actions.updateDetails(0)(guestDetailsNonMember)
// })
// expect(result.current.activeRoom).toEqual(1)
// await act(async () => {
// result.current.actions.setStep(StepEnum.breakfast)
// })
// expect(result.current.activeRoom).toEqual(1)
// })
// test("can go back and modify room 1 after completion", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// await act(async () => {
// result.current.actions.updateBedType(0)({
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// })
// result.current.actions.updateBreakfast(0)(breakfastPackage)
// result.current.actions.updateDetails(0)(guestDetailsNonMember)
// })
// // now we are at room 2
// expect(result.current.activeRoom).toEqual(1)
// await act(async () => {
// result.current.actions.setStep(StepEnum.breakfast) // click "modify"
// })
// expect(result.current.activeRoom).toEqual(1)
// await act(async () => {
// result.current.actions.updateBreakfast(1)(breakfastPackage)
// })
// // going back to room 2
// expect(result.current.activeRoom).toEqual(1)
// })
// test("breakfast step should be hidden when breakfast is included", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({ breakfastPackages: null }),
// }
// )
// const room1 = result.current.rooms[0]
// expect(Object.keys(room1.steps)).not.toContain(StepEnum.breakfast)
// const room2 = result.current.rooms[1]
// expect(Object.keys(room2.steps)).not.toContain(StepEnum.breakfast)
// })
// test("select bed step should be skipped when there is only one bedtype", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({
// bedTypes: [bedType.queen],
// breakfastPackages: [
// {
// code: "TEST",
// description: "Description",
// localPrice: {
// currency: "SEK",
// price: "100",
// totalPrice: "100",
// },
// requestedPrice: {
// currency: "SEK",
// price: "100",
// totalPrice: "100",
// },
// packageType: PackageTypeEnum.BreakfastAdult,
// },
// ],
// }),
// }
// )
// const room1 = result.current.rooms[0]
// expect(room1.steps[StepEnum.selectBed].isValid).toEqual(true)
// expect(room1.currentStep).toEqual(StepEnum.breakfast)
// const room2 = result.current.rooms[1]
// expect(room2.steps[StepEnum.selectBed].isValid).toEqual(true)
// expect(room2.currentStep).toEqual(null)
// })
// describe("price calculation", () => {
// test("total price should be set properly", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// const publicRate = roomRate.publicRate.localPrice.pricePerStay
// const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
// const initialTotalPrice = publicRate * result.current.rooms.length
// expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice)
// // room 1
// await act(async () => {
// result.current.actions.updateBedType(0)({
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// })
// result.current.actions.updateBreakfast(0)(breakfastPackage)
// })
// let expectedTotalPrice =
// initialTotalPrice + Number(breakfastPackage.localPrice.price)
// expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// await act(async () => {
// result.current.actions.updateDetails(0)(guestDetailsMember)
// })
// expectedTotalPrice =
// memberRate + publicRate + Number(breakfastPackage.localPrice.price)
// expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// // room 2
// await act(async () => {
// result.current.actions.updateBedType(1)({
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// })
// result.current.actions.updateBreakfast(1)(breakfastPackage)
// })
// expectedTotalPrice =
// memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2
// expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// await act(async () => {
// result.current.actions.updateDetails(1)(guestDetailsNonMember)
// })
// expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// })
// test("room price should be set properly", async () => {
// const { result } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper(),
// }
// )
// const publicRate = roomRate.publicRate.localPrice.pricePerStay
// const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
// let room1 = result.current.rooms[0]
// expect(room1.room.roomPrice.perStay.local.price).toEqual(publicRate)
// let room2 = result.current.rooms[0]
// expect(room2.room.roomPrice.perStay.local.price).toEqual(publicRate)
// await act(async () => {
// result.current.actions.updateDetails(0)(guestDetailsMember)
// })
// room1 = result.current.rooms[0]
// expect(room1.room.roomPrice.perStay.local.price).toEqual(memberRate)
// })
// })
// describe("change room", () => {
// test("changing to room with new bedtypes requires selecting bed again", async () => {
// const { result: firstRun } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
// }
// )
// const selectedBedType = {
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// }
// // add bedtype
// await act(async () => {
// firstRun.current.actions.updateBedType(0)(selectedBedType)
// })
// await act(async () => {
// firstRun.current.actions.updateBreakfast(0)(false) // 'no breakfast' selected
// })
// await act(async () => {
// firstRun.current.actions.updateDetails(0)(guestDetailsNonMember)
// })
// const updatedBooking = {
// ...booking,
// rooms: booking.rooms.map((r) => ({
// ...r,
// roomTypeCode: "NEW",
// })),
// }
// // render again to change the bedtypes
// const { result: secondRun } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({
// bookingParams: updatedBooking,
// bedTypes: [bedType.single, bedType.queen],
// }),
// }
// )
// await waitFor(() => {
// const secondRunRoom = secondRun.current.rooms[0]
// // bed type should be unset since the bed types have changed
// expect(secondRunRoom.room.bedType).toEqual(undefined)
// // bed step should be unselected
// expect(secondRunRoom.currentStep).toBe(StepEnum.selectBed)
// expect(secondRunRoom.steps[StepEnum.selectBed].isValid).toBe(false)
// // other steps should still be selected
// expect(secondRunRoom.room.breakfast).toBe(false)
// expect(secondRunRoom.steps[StepEnum.breakfast]?.isValid).toBe(true)
// expect(secondRunRoom.room.guest).toEqual(guestDetailsNonMember)
// expect(secondRunRoom.steps[StepEnum.details].isValid).toBe(true)
// })
// })
// test("changing to room with single bedtype option should skip step", async () => {
// const { result: firstRun } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
// }
// )
// const selectedBedType = {
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// }
// // add bedtype
// await act(async () => {
// firstRun.current.actions.updateBedType(0)(selectedBedType)
// })
// await act(async () => {
// firstRun.current.actions.updateBreakfast(0)(breakfastPackage)
// })
// const updatedBooking = {
// ...booking,
// rooms: booking.rooms.map((r) => ({
// ...r,
// roomTypeCode: "NEW",
// })),
// }
// // render again to change the bedtypes
// const { result: secondRun } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({
// bookingParams: updatedBooking,
// bedTypes: [bedType.queen],
// }),
// }
// )
// await waitFor(() => {
// const secondRunRoom = secondRun.current.rooms[0]
// expect(secondRunRoom.room.bedType).toEqual({
// roomTypeCode: bedType.queen.value,
// description: bedType.queen.description,
// })
// expect(secondRunRoom.steps[StepEnum.selectBed].isValid).toBe(true)
// expect(secondRunRoom.steps[StepEnum.breakfast]?.isValid).toBe(true)
// expect(secondRunRoom.steps[StepEnum.details].isValid).toBe(false)
// expect(secondRunRoom.currentStep).toBe(StepEnum.details)
// })
// })
// test("if booking has changed, stored values should be discarded", async () => {
// const { result: firstRun } = renderHook(
// () => useEnterDetailsStore((state) => state),
// {
// wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
// }
// )
// const selectedBedType = {
// roomTypeCode: bedType.king.value,
// description: bedType.king.description,
// }
// // add bedtype
// await act(async () => {
// firstRun.current.actions.updateBedType(0)(selectedBedType)
// })
// await act(async () => {
// firstRun.current.actions.updateBreakfast(0)(breakfastPackage)
// })
// const updatedBooking = {
// ...booking,
// hotelId: "0001",
// fromDate: "2030-01-01",
// toDate: "2030-01-02",
// }
// renderHook(() => useEnterDetailsStore((state) => state), {
// wrapper: createWrapper({
// bookingParams: updatedBooking,
// bedTypes: [bedType.queen],
// }),
// })
// await waitFor(() => {
// const storageItem = window.sessionStorage.getItem(detailsStorageName)
// expect(storageItem).toBe(null)
// })
// })
// })
// })

View File

@@ -10,8 +10,6 @@ import type {
DetailsState,
PersistedState,
RoomState,
RoomStatus,
RoomStep,
} from "@/types/stores/enter-details"
import type { SafeUser } from "@/types/user"
@@ -160,7 +158,7 @@ export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
rate.requestedPrice.pricePerStay
),
}
: undefined,
: total.requested,
local: {
currency: rate.localPrice.currency,
price: add(total.local.price ?? 0, rate.localPrice.pricePerStay),
@@ -179,11 +177,12 @@ export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
export function calcTotalPrice(
rooms: RoomState[],
totalPrice: Price,
isMember: boolean
currency: Price["local"]["currency"],
isMember: boolean,
nights: number
) {
return rooms.reduce<Price>(
(acc, room, index) => {
(acc, { room }, index) => {
const isFirstRoomAndMember = index === 0 && isMember
const join = Boolean(room.guest.join || room.guest.membershipNo)
@@ -193,10 +192,10 @@ export function calcTotalPrice(
)
const breakfastRequestedPrice = room.breakfast
? room.breakfast.requestedPrice?.totalPrice ?? 0
? parseInt(room.breakfast.requestedPrice?.price ?? 0)
: 0
const breakfastLocalPrice = room.breakfast
? room.breakfast.localPrice?.totalPrice ?? 0
? parseInt(room.breakfast.localPrice?.price ?? 0)
: 0
const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => {
@@ -213,7 +212,7 @@ export function calcTotalPrice(
price: add(
acc.requested?.price ?? 0,
roomPrice.perStay.requested.price,
breakfastRequestedPrice
breakfastRequestedPrice * room.adults * nights
),
}
: undefined,
@@ -222,7 +221,7 @@ export function calcTotalPrice(
price: add(
acc.local.price,
roomPrice.perStay.local.price,
breakfastLocalPrice,
breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal
),
},
@@ -232,57 +231,40 @@ export function calcTotalPrice(
},
{
requested: undefined,
local: { currency: totalPrice.local.currency, price: 0 },
local: { currency, price: 0 },
}
)
}
export const selectRoomStatus = (state: DetailsState, index?: number) =>
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
]
export const selectRoom = (state: DetailsState, index?: number) =>
state.rooms[index ?? state.bookingProgress.currentRoomIndex]
export const selectRoomSteps = (state: DetailsState, index?: number) =>
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
].steps
export const selectPreviousSteps = (
state: DetailsState,
index?: number
): {
[StepEnum.selectBed]?: RoomStep
[StepEnum.breakfast]?: RoomStep
[StepEnum.details]?: RoomStep
} => {
const roomStatus =
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
]
const stepKeys = Object.keys(roomStatus.steps)
const currentStepIndex = stepKeys.indexOf(`${roomStatus.currentStep}`)
return Object.entries(roomStatus.steps)
.slice(0, currentStepIndex)
.reduce((acc, [key, value]) => {
return { ...acc, [key]: value }
}, {})
export function getFirstInteractiveStepOfRoom(room: RoomState["room"]) {
if (!room.bedType) {
return StepEnum.selectBed
}
if (room.breakfast !== false) {
return StepEnum.breakfast
}
return StepEnum.details
}
export const selectNextStep = (roomStatus: RoomStatus) => {
if (roomStatus.currentStep === null) {
export function findNextInvalidStep(roomState: RoomState) {
return (
Object.values(roomState.steps).find((stp) => !stp.isValid)?.step ??
getFirstInteractiveStepOfRoom(roomState.room)
)
}
export const selectNextStep = (room: RoomState) => {
if (room.currentStep === null) {
throw new Error("getNextStep: currentStep is null")
}
if (!roomStatus.steps[roomStatus.currentStep]?.isValid) {
return roomStatus.currentStep
if (!room.steps[room.currentStep]?.isValid) {
return room.currentStep
}
const stepsArray = Object.values(roomStatus.steps)
const stepsArray = Object.values(room.steps)
const currentIndex = stepsArray.findIndex(
(step) => step?.step === roomStatus.currentStep
(step) => step?.step === room.currentStep
)
if (currentIndex === stepsArray.length - 1) {
return null
@@ -295,52 +277,27 @@ export const selectNextStep = (roomStatus: RoomStatus) => {
return nextInvalidStep?.step ?? null
}
export const selectBookingProgress = (state: DetailsState) =>
state.bookingProgress
export const checkBookingProgress = (state: DetailsState) => {
return state.bookingProgress.roomStatuses.every((r) => r.isComplete)
}
export const checkRoomProgress = (state: DetailsState) => {
const steps = selectRoomSteps(state)
export const checkRoomProgress = (steps: RoomState["steps"]) => {
return Object.values(steps)
.filter(Boolean)
.every((step) => step.isValid)
}
export function handleStepProgression(state: DetailsState) {
const isAllRoomsCompleted = checkBookingProgress(state)
export function handleStepProgression(room: RoomState, state: DetailsState) {
const isAllRoomsCompleted = state.rooms.every((r) => r.isComplete)
if (isAllRoomsCompleted) {
const roomStatus = selectRoomStatus(state)
roomStatus.currentStep = null
state.bookingProgress.canProceedToPayment = true
return
}
room.currentStep = null
state.canProceedToPayment = true
} else if (room.isComplete) {
room.currentStep = null
const nextRoomIndex = state.rooms.findIndex((r) => !r.isComplete)
state.activeRoom = nextRoomIndex
const roomStatus = selectRoomStatus(state)
if (roomStatus.isComplete) {
const nextRoomIndex = state.bookingProgress.roomStatuses.findIndex(
(room) => !room.isComplete
)
roomStatus.lastCompletedStep = roomStatus.currentStep ?? undefined
roomStatus.currentStep = null
const nextRoomStatus = selectRoomStatus(state, nextRoomIndex)
nextRoomStatus.currentStep =
Object.values(nextRoomStatus.steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
const nextStep = selectNextStep(nextRoomStatus)
nextRoomStatus.currentStep = nextStep
state.bookingProgress.currentRoomIndex = nextRoomIndex
return
}
const nextStep = selectNextStep(roomStatus)
if (nextStep !== null && roomStatus.currentStep !== null) {
roomStatus.lastCompletedStep = roomStatus.currentStep
roomStatus.currentStep = nextStep
return
const nextRoom = state.rooms[nextRoomIndex]
const nextStep = selectNextStep(nextRoom)
nextRoom.currentStep = nextStep
} else if (selectNextStep(room)) {
room.currentStep = selectNextStep(room)
}
}
@@ -357,11 +314,7 @@ export function readFromSessionStorage(): PersistedState | undefined {
const parsedData = JSON.parse(storedData) as PersistedState
if (
!parsedData.booking ||
!parsedData.rooms ||
!parsedData.bookingProgress
) {
if (!parsedData.booking || !parsedData.rooms) {
return undefined
}

View File

@@ -3,6 +3,8 @@ import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { dt } from "@/lib/dt"
import { DetailsContext } from "@/contexts/Details"
import {
@@ -10,22 +12,20 @@ import {
calcTotalPrice,
checkRoomProgress,
extractGuestFromUser,
findNextInvalidStep,
getRoomPrice,
getTotalPrice,
handleStepProgression,
selectPreviousSteps,
selectRoom,
selectRoomStatus,
writeToSessionStorage,
} from "./helpers"
import type { BreakfastPackages } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
InitialState,
RoomState,
RoomStatus,
RoomStep,
} from "@/types/stores/enter-details"
import type { SafeUser } from "@/types/user"
@@ -46,7 +46,8 @@ export const detailsStorageName = "rooms-details-storage"
export function createDetailsStore(
initialState: InitialState,
searchParams: string,
user: SafeUser
user: SafeUser,
breakfastPackages: BreakfastPackages | null
) {
const isMember = !!user
@@ -73,21 +74,6 @@ export function createDetailsStore(
})
const rooms: RoomState[] = initialState.rooms.map((room, idx) => {
return {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
guest: isMember
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
}
})
const roomStatuses: RoomStatus[] = initialState.rooms.map((room, idx) => {
const steps: RoomStatus["steps"] = {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
@@ -103,69 +89,112 @@ export function createDetailsStore(
},
}
if (initialState.breakfast === false) {
if (room.breakfastIncluded || !breakfastPackages?.length) {
delete steps[StepEnum.breakfast]
}
const currentStep =
idx === 0
? Object.values(steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
: null
Object.values(steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
return {
room: {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
!breakfastPackages?.length || room.breakfastIncluded
? false
: undefined,
guest:
isMember && idx === 0
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
},
currentStep,
isComplete: false,
currentStep: currentStep,
lastCompletedStep: undefined,
steps,
}
})
return create<DetailsState>()((set, get) => ({
searchParamString: searchParams,
return create<DetailsState>()((set) => ({
activeRoom: 0,
booking: initialState.booking,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
breakfastPackages,
canProceedToPayment: false,
isSubmittingDisabled: false,
isSummaryOpen: false,
isPriceDetailsModalOpen: false,
lastRoom: initialState.booking.rooms.length - 1,
rooms,
searchParamString: searchParams,
totalPrice: initialTotalPrice,
vat: initialState.vat,
rooms,
bookingProgress: {
currentRoomIndex: 0,
roomStatuses,
canProceedToPayment: false,
},
actions: {
setStep(step: StepEnum | null, roomIndex?: number) {
if (step === null) {
return
}
return set(
produce((state: DetailsState) => {
const currentRoomIndex =
roomIndex ?? state.bookingProgress.currentRoomIndex
const previousSteps = selectPreviousSteps(state, roomIndex)
const arePreviousStepsCompleted = Object.values(
previousSteps
).every((step: RoomStep) => step.isValid)
const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses
.slice(0, currentRoomIndex)
.every((room) => room.isComplete)
const roomStatus = selectRoomStatus(state, roomIndex)
if (arePreviousRoomsCompleted && arePreviousStepsCompleted) {
roomStatus.currentStep = step
if (roomIndex !== undefined) {
state.bookingProgress.currentRoomIndex = roomIndex
setStep(idx) {
return function (step) {
return set(
produce((state: DetailsState) => {
const isSameRoom = idx === state.activeRoom
const room = state.rooms[idx]
if (isSameRoom) {
// Closed same accordion as was open
if (step === room.currentStep) {
if (room.isComplete) {
// Room is complete, move to next room or payment
const nextRoomIdx = state.rooms.findIndex(
(r) => !r.isComplete
)
state.activeRoom = nextRoomIdx
// Done, proceed to payment
if (nextRoomIdx === -1) {
room.currentStep = null
} else {
const nextRoom = state.rooms[nextRoomIdx]
const nextInvalidStep = findNextInvalidStep(nextRoom)
nextRoom.currentStep = nextInvalidStep
}
} else {
room.currentStep = findNextInvalidStep(room)
}
} else {
if (room.steps[step]?.isValid) {
room.currentStep = step
} else {
room.currentStep = findNextInvalidStep(room)
}
}
} else {
const arePreviousRoomsCompleted = state.rooms
.slice(0, idx)
.every((room) => room.isComplete)
if (arePreviousRoomsCompleted) {
state.activeRoom = idx
if (room.steps[step]?.isValid) {
room.currentStep = step
} else {
room.currentStep = findNextInvalidStep(room)
}
} else {
const firstIncompleteRoom = state.rooms.findIndex(
(r) => !r.isComplete
)
state.activeRoom = firstIncompleteRoom
if (firstIncompleteRoom === -1) {
// All rooms are done, proceed to payment
room.currentStep = null
} else {
const nextRoom = state.rooms[firstIncompleteRoom]
nextRoom.currentStep = findNextInvalidStep(nextRoom)
}
}
}
}
})
)
})
)
}
},
setIsSubmittingDisabled(isSubmittingDisabled) {
return set(
@@ -189,170 +218,240 @@ export function createDetailsStore(
})
)
},
togglePriceDetailsModalOpen() {
return set(
produce((state: DetailsState) => {
state.isPriceDetailsModalOpen = !state.isPriceDetailsModalOpen
})
)
},
updateBedType(bedType) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.selectBed].isValid = true
updateBedType(idx) {
return function (bedType) {
return set(
produce((state: DetailsState) => {
state.rooms[idx].steps[StepEnum.selectBed].isValid = true
state.rooms[idx].room.bedType = bedType
const room = selectRoom(state)
room.bedType = bedType
handleStepProgression(state.rooms[idx], state)
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
writeToSessionStorage({
activeRoom: state.activeRoom,
booking: state.booking,
rooms: state.rooms,
})
})
})
)
)
}
},
updateBreakfast(breakfast) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
if (roomStatus.steps[StepEnum.breakfast]) {
roomStatus.steps[StepEnum.breakfast].isValid = true
}
updateBreakfast(idx) {
return function (breakfast) {
return set(
produce((state: DetailsState) => {
const currentRoom = state.rooms[idx]
if (currentRoom.steps[StepEnum.breakfast]) {
currentRoom.steps[StepEnum.breakfast].isValid = true
}
const stateTotalRequestedPrice =
state.totalPrice.requested?.price || 0
const currentTotalPriceRequested = state.totalPrice.requested
let stateTotalRequestedPrice = 0
if (currentTotalPriceRequested) {
stateTotalRequestedPrice = currentTotalPriceRequested.price
}
const stateTotalLocalPrice = state.totalPrice.local.price
const stateTotalLocalPrice = state.totalPrice.local.price
const addToTotalPrice =
(state.breakfast === undefined || state.breakfast === false) &&
!!breakfast
const addToTotalPrice =
(currentRoom.room.breakfast === undefined ||
currentRoom.room.breakfast === false) &&
!!breakfast
const subtractFromTotalPrice =
(state.breakfast === undefined || state.breakfast) &&
breakfast === false
const subtractFromTotalPrice =
currentRoom.room.breakfast && breakfast === false
if (addToTotalPrice) {
const breakfastTotalRequestedPrice = parseInt(
breakfast.requestedPrice.totalPrice
)
const breakfastTotalPrice = parseInt(
breakfast.localPrice.totalPrice
const nights = dt(state.booking.toDate).diff(
state.booking.fromDate,
"days"
)
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price:
stateTotalRequestedPrice + breakfastTotalRequestedPrice,
},
local: {
currency: breakfast.localPrice.currency,
price: stateTotalLocalPrice + breakfastTotalPrice,
},
}
}
if (subtractFromTotalPrice) {
let currency = state.totalPrice.local.currency
let currentBreakfastTotalPrice = 0
let currentBreakfastTotalRequestedPrice = 0
if (state.breakfast) {
currentBreakfastTotalPrice = parseInt(
state.breakfast.localPrice.totalPrice
)
currentBreakfastTotalRequestedPrice = parseInt(
state.breakfast.requestedPrice.totalPrice
)
currency = state.breakfast.localPrice.currency
if (addToTotalPrice) {
const breakfastTotalRequestedPrice =
parseInt(breakfast.requestedPrice.price) *
currentRoom.room.adults *
nights
const breakfastTotalPrice =
parseInt(breakfast.localPrice.price) *
currentRoom.room.adults *
nights
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price:
stateTotalRequestedPrice + breakfastTotalRequestedPrice,
},
local: {
currency: breakfast.localPrice.currency,
price: stateTotalLocalPrice + breakfastTotalPrice,
},
}
}
let requestedPrice =
stateTotalRequestedPrice - currentBreakfastTotalRequestedPrice
if (requestedPrice < 0) {
requestedPrice = 0
}
let localPrice = stateTotalLocalPrice - currentBreakfastTotalPrice
if (localPrice < 0) {
localPrice = 0
if (subtractFromTotalPrice) {
let currency = state.totalPrice.local.currency
let currentBreakfastTotalPrice = 0
let currentBreakfastTotalRequestedPrice = 0
if (currentRoom.room.breakfast) {
currentBreakfastTotalPrice =
parseInt(currentRoom.room.breakfast.localPrice.price) *
currentRoom.room.adults *
nights
currentBreakfastTotalRequestedPrice =
parseInt(
currentRoom.room.breakfast.requestedPrice.totalPrice
) *
currentRoom.room.adults *
nights
currency = currentRoom.room.breakfast.localPrice.currency
}
let requestedPrice =
stateTotalRequestedPrice - currentBreakfastTotalRequestedPrice
if (requestedPrice < 0) {
requestedPrice = 0
}
let localPrice =
stateTotalLocalPrice - currentBreakfastTotalPrice
if (localPrice < 0) {
localPrice = 0
}
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price: requestedPrice,
},
local: {
currency,
price: localPrice,
},
}
}
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price: requestedPrice,
},
local: {
currency,
price: localPrice,
},
}
}
currentRoom.room.breakfast = breakfast
const room = selectRoom(state)
room.breakfast = breakfast
handleStepProgression(currentRoom, state)
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
writeToSessionStorage({
activeRoom: state.activeRoom,
booking: state.booking,
rooms: state.rooms,
})
})
})
)
)
}
},
updateDetails(data) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.details].isValid = true
updateDetails(idx) {
return function (data) {
return set(
produce((state: DetailsState) => {
state.rooms[idx].steps[StepEnum.details].isValid = true
const room = selectRoom(state)
room.guest.countryCode = data.countryCode
room.guest.dateOfBirth = data.dateOfBirth
room.guest.email = data.email
room.guest.firstName = data.firstName
room.guest.join = data.join
room.guest.lastName = data.lastName
state.rooms[idx].room.guest.countryCode = data.countryCode
state.rooms[idx].room.guest.dateOfBirth = data.dateOfBirth
state.rooms[idx].room.guest.email = data.email
state.rooms[idx].room.guest.firstName = data.firstName
state.rooms[idx].room.guest.join = data.join
state.rooms[idx].room.guest.lastName = data.lastName
if (data.join) {
room.guest.membershipNo = undefined
} else {
room.guest.membershipNo = data.membershipNo
}
room.guest.phoneNumber = data.phoneNumber
room.guest.zipCode = data.zipCode
if (data.join) {
state.rooms[idx].room.guest.membershipNo = undefined
} else {
state.rooms[idx].room.guest.membershipNo = data.membershipNo
}
state.rooms[idx].room.guest.phoneNumber = data.phoneNumber
state.rooms[idx].room.guest.zipCode = data.zipCode
room.roomPrice = getRoomPrice(
room.roomRate,
Boolean(data.join || data.membershipNo || isMember)
)
state.rooms[idx].room.roomPrice = getRoomPrice(
state.rooms[idx].room.roomRate,
Boolean(data.join || data.membershipNo || isMember)
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice,
isMember
)
const nights = dt(state.booking.toDate).diff(
state.booking.fromDate,
"days"
)
const isAllStepsCompleted = checkRoomProgress(state)
if (isAllStepsCompleted) {
roomStatus.isComplete = true
}
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice.local.currency,
isMember,
nights
)
handleStepProgression(state)
const isAllStepsCompleted = checkRoomProgress(
state.rooms[idx].steps
)
if (isAllStepsCompleted) {
state.rooms[idx].isComplete = true
}
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
handleStepProgression(state.rooms[idx], state)
writeToSessionStorage({
activeRoom: state.activeRoom,
booking: state.booking,
rooms: state.rooms,
})
})
})
)
)
}
},
updateMultiroomDetails(idx) {
return function (data) {
return set(
produce((state: DetailsState) => {
state.rooms[idx].steps[StepEnum.details].isValid = true
state.rooms[idx].room.guest.countryCode = data.countryCode
state.rooms[idx].room.guest.email = data.email
state.rooms[idx].room.guest.firstName = data.firstName
state.rooms[idx].room.guest.join = data.join
state.rooms[idx].room.guest.lastName = data.lastName
if (data.join) {
state.rooms[idx].room.guest.membershipNo = undefined
} else {
state.rooms[idx].room.guest.membershipNo = data.membershipNo
}
state.rooms[idx].room.guest.phoneNumber = data.phoneNumber
const getMemberPrice = Boolean(data.join || data.membershipNo)
state.rooms[idx].room.roomPrice = getRoomPrice(
state.rooms[idx].room.roomRate,
getMemberPrice
)
const nights = dt(state.booking.toDate).diff(
state.booking.fromDate,
"days"
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice.local.currency,
getMemberPrice,
nights
)
const isAllStepsCompleted = checkRoomProgress(
state.rooms[idx].steps
)
if (isAllStepsCompleted) {
state.rooms[idx].isComplete = true
}
handleStepProgression(state.rooms[idx], state)
writeToSessionStorage({
activeRoom: state.activeRoom,
booking: state.booking,
rooms: state.rooms,
})
})
)
}
},
updateSeachParamString(searchParamString) {
return set(

View File

@@ -1,633 +0,0 @@
import { describe, expect, test } from "@jest/globals"
import { act, renderHook, waitFor } from "@testing-library/react"
import { type PropsWithChildren } from "react"
import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsMember,
guestDetailsNonMember,
roomPrice,
roomRate,
} from "@/__mocks__/hotelReservation"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { selectRoom, selectRoomStatus } from "./helpers"
import { detailsStorageName, useEnterDetailsStore } from "."
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { PersistedState } from "@/types/stores/enter-details"
jest.mock("react", () => ({
...jest.requireActual("react"),
cache: jest.fn(),
}))
jest.mock("@/server/utils", () => ({
toLang: () => Lang.en,
}))
jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
}))
interface CreateWrapperParams {
showBreakfastStep?: boolean
breakfastIncluded?: boolean
mustBeGuaranteed?: boolean
bookingParams?: SelectRateSearchParams
bedTypes?: BedTypeSelection[]
}
function createWrapper(params: Partial<CreateWrapperParams> = {}) {
const {
showBreakfastStep = true,
breakfastIncluded = false,
mustBeGuaranteed = false,
bookingParams = booking,
bedTypes = [bedType.king, bedType.queen],
} = params
return function Wrapper({ children }: PropsWithChildren) {
return (
<EnterDetailsProvider
booking={bookingParams}
showBreakfastStep={showBreakfastStep}
roomsData={[
{
bedTypes,
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
{
bedTypes,
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
]}
searchParamsStr=""
user={null}
vat={0}
>
{children}
</EnterDetailsProvider>
)
}
}
describe("Enter Details Store", () => {
beforeEach(() => {
window.sessionStorage.clear()
})
describe("initial state", () => {
test("initialize with correct default values", () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const state = result.current
expect(state.booking).toEqual(booking)
expect(state.breakfast).toEqual(undefined)
// room 1
const room1Status = selectRoomStatus(result.current, 0)
const room1 = selectRoom(result.current, 0)
expect(room1Status.currentStep).toBe(StepEnum.selectBed)
expect(room1.roomPrice.perNight.local.price).toEqual(
roomRate.publicRate.localPrice.pricePerNight
)
expect(room1.bedType).toEqual(undefined)
expect(Object.values(room1.guest).every((value) => value === ""))
// room 2
const room2Status = selectRoomStatus(result.current, 1)
const room2 = selectRoom(result.current, 1)
expect(room2Status.currentStep).toBe(null)
expect(room2.roomPrice.perNight.local.price).toEqual(
room2.roomRate.publicRate.localPrice.pricePerNight
)
expect(room2.bedType).toEqual(undefined)
expect(Object.values(room2.guest).every((value) => value === ""))
})
test("initialize with correct values from session storage", () => {
const storage: PersistedState = {
booking: booking,
bookingProgress: {
currentRoomIndex: 0,
canProceedToPayment: true,
roomStatuses: [
{
isComplete: false,
currentStep: StepEnum.selectBed,
lastCompletedStep: undefined,
steps: {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: true,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: true,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: true,
},
},
},
],
},
rooms: [
{
roomFeatures: null,
roomRate: roomRate,
roomType: "Classic Double",
cancellationText: "Non-refundable",
rateDetails: [],
bedType: {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
},
adults: 1,
childrenInRoom: [],
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomPrice: roomPrice,
},
],
}
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
expect(result.current.booking).toEqual(storage.booking)
expect(result.current.rooms[0]).toEqual(storage.rooms[0])
expect(result.current.bookingProgress).toEqual(storage.bookingProgress)
})
})
test("add bedtype and proceed to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
await act(async () => {
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
const room = selectRoom(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room.bedType).toEqual(selectedBedType)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
})
test("complete step and navigate to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
// Room 1
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.canProceedToPayment).toBe(false)
// Room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.canProceedToPayment).toBe(true)
})
test("all steps needs to be completed before going to next room", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 1)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
})
test("can go back and modify room 1 after completion", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
result.current.actions.updateDetails(guestDetailsNonMember)
})
// now we are at room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 0) // click "modify"
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
// going back to room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
})
test("breakfast step should be hidden when breakfast is included", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ showBreakfastStep: false }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(Object.keys(room1Status.steps)).not.toContain(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(Object.keys(room2Status.steps)).not.toContain(StepEnum.breakfast)
})
test("select bed step should be skipped when there is only one bedtype", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.queen] }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(room1Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room1Status.currentStep).toEqual(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(room2Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room2Status.currentStep).toEqual(null)
})
describe("price calculation", () => {
test("total price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
const initialTotalPrice = publicRate * result.current.rooms.length
expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice)
// room 1
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
let expectedTotalPrice =
initialTotalPrice + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// room 2
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
})
test("room price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
let room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(publicRate)
let room2 = selectRoom(result.current, 0)
expect(room2.roomPrice.perStay.local.price).toEqual(publicRate)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(memberRate)
})
})
describe("change room", () => {
test("changing to room with new bedtypes requires selecting bed again", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(false) // 'no breakfast' selected
})
await act(async () => {
firstRun.current.actions.updateDetails(guestDetailsNonMember)
})
const updatedBooking = {
...booking,
rooms: booking.rooms.map((r) => ({
...r,
roomTypeCode: "NEW",
})),
}
// render again to change the bedtypes
const { result: secondRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.single, bedType.queen],
}),
}
)
await waitFor(() => {
const room = selectRoom(secondRun.current, 0)
const roomStatus = selectRoomStatus(secondRun.current, 0)
// bed type should be unset since the bed types have changed
expect(room.bedType).toEqual(undefined)
// bed step should be unselected
expect(roomStatus.currentStep).toBe(StepEnum.selectBed)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toBe(false)
// other steps should still be selected
expect(room.breakfast).toBe(false)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toBe(true)
expect(room.guest).toEqual(guestDetailsNonMember)
expect(roomStatus.steps[StepEnum.details].isValid).toBe(true)
})
})
test("changing to room with single bedtype option should skip step", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(breakfastPackage)
})
const updatedBooking = {
...booking,
rooms: booking.rooms.map((r) => ({
...r,
roomTypeCode: "NEW",
})),
}
// render again to change the bedtypes
const { result: secondRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.queen],
}),
}
)
await waitFor(() => {
const room = selectRoom(secondRun.current, 0)
const roomStatus = selectRoomStatus(secondRun.current, 0)
expect(room.bedType).toEqual({
roomTypeCode: bedType.queen.value,
description: bedType.queen.description,
})
expect(roomStatus.steps[StepEnum.selectBed].isValid).toBe(true)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toBe(true)
expect(roomStatus.steps[StepEnum.details].isValid).toBe(false)
expect(roomStatus.currentStep).toBe(StepEnum.details)
})
})
test("if booking has changed, stored values should be discarded", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(breakfastPackage)
})
const updatedBooking = {
...booking,
hotelId: "0001",
fromDate: "2030-01-01",
toDate: "2030-01-02",
}
renderHook(() => useEnterDetailsStore((state) => state), {
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.queen],
}),
})
await waitFor(() => {
const storageItem = window.sessionStorage.getItem(detailsStorageName)
expect(storageItem).toBe(null)
})
})
})
})

View File

@@ -200,7 +200,7 @@ export function createRatesStore({
`room[${idx}].counterratecode`,
isMemberRate
? selectedRate.product.productType.public.rateCode
: selectedRate.product.productType.member?.rateCode ?? ""
: (selectedRate.product.productType.member?.rateCode ?? "")
)
searchParams.set(
`room[${idx}].ratecode`,
@@ -236,6 +236,7 @@ export function createRatesStore({
booking,
filterOptions,
hotelType,
isUserLoggedIn,
packages,
pathname,
petRoomPackage: packages.find(

View File

@@ -1,104 +0,0 @@
import { create } from "zustand"
import { calculateRoomSummary } from "./helper"
import type {
RoomPackageCodeEnum,
RoomPackages,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type {
Child,
Rate,
RateCode,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
export interface RateSummaryParams {
getFilteredRooms: (roomIndex: number) => RoomConfiguration[]
availablePackages: RoomPackages
roomCategories: Array<{ name: string; roomTypes: Array<{ code: string }> }>
selectedPackagesByRoom: Record<number, RoomPackageCodeEnum[]>
}
interface RateSelectionState {
selectedRates: (RateCode | undefined)[]
rateSummary: (Rate | null)[]
isPriceDetailsModalOpen: boolean
isSummaryOpen: boolean
guestsInRooms: { adults: number; children?: Child[] }[]
modifyRateIndex: number | null
modifyRate: (index: number) => void
closeModifyRate: () => void
selectRate: (index: number, rate: RateCode | undefined) => void
initializeRates: (count: number) => void
calculateRateSummary: ({
getFilteredRooms,
availablePackages,
roomCategories,
}: RateSummaryParams) => void
getSelectedRateSummary: () => Rate[]
togglePriceDetailsModalOpen: () => void
toggleSummaryOpen: () => void
setGuestsInRooms: (index: number, adults: number, children?: Child[]) => void
}
export const useRateSelectionStore = create<RateSelectionState>((set, get) => ({
selectedRates: [],
rateSummary: [],
isPriceDetailsModalOpen: false,
isSummaryOpen: false,
guestsInRooms: [{ adults: 1 }],
modifyRateIndex: null,
modifyRate: (index) => set({ modifyRateIndex: index }),
closeModifyRate: () => set({ modifyRateIndex: null }),
selectRate: (index, rate) => {
set((state) => {
const newRates = [...state.selectedRates]
newRates[index] = rate
return {
selectedRates: newRates,
}
})
},
initializeRates: (count) =>
set({ selectedRates: new Array(count).fill(undefined) }),
calculateRateSummary: (params) => {
const { selectedRates } = get()
const summaries = selectedRates.map((selectedRate, roomIndex) => {
if (!selectedRate) return null
return calculateRoomSummary({
selectedRate,
roomIndex,
...params,
})
})
set({ rateSummary: summaries })
},
getSelectedRateSummary: () => {
const { rateSummary } = get()
return rateSummary.filter((summary): summary is Rate => summary !== null)
},
togglePriceDetailsModalOpen: () => {
set((state) => ({
isPriceDetailsModalOpen: !state.isPriceDetailsModalOpen,
}))
},
toggleSummaryOpen: () => {
set((state) => ({
isSummaryOpen: !state.isSummaryOpen,
}))
},
setGuestsInRooms: (index, adults, children) => {
set((state) => ({
guestsInRooms: [
...state.guestsInRooms.slice(0, index),
{ adults, children },
...state.guestsInRooms.slice(index + 1),
],
}))
},
}))

View File

@@ -5,7 +5,7 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat
import type { linkedReservationSchema } from "@/server/routers/booking/output"
export interface LinkedReservationSchema
extends z.output<typeof linkedReservationSchema> {}
extends z.output<typeof linkedReservationSchema> { }
export interface BookingConfirmationRoomsProps
extends Pick<BookingConfirmation, "booking"> {

View File

@@ -21,9 +21,6 @@ export type BedTypeSelection = {
}
| undefined
}
export type BedTypeProps = {
bedTypes: BedTypeSelection[]
}
export interface BedTypeFormSchema extends z.output<typeof bedTypeFormSchema> {}

View File

@@ -12,7 +12,3 @@ export interface BreakfastPackages
export interface BreakfastPackage
extends z.output<typeof breakfastPackageSchema> {}
export interface BreakfastProps {
packages: BreakfastPackages
}

View File

@@ -2,13 +2,15 @@ import type { z } from "zod"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
import type { SafeUser } from "@/types/user"
import type { multiroomDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
import type {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
} from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
import type { Price } from "../price"
export type DetailsSchema = z.output<typeof guestDetailsSchema>
export type MultiroomDetailsSchema = z.output<typeof multiroomDetailsSchema>
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
export interface RoomPrice {
@@ -16,19 +18,12 @@ export interface RoomPrice {
perStay: Price
}
type MemberPrice = {
currency: string
price: number
}
export interface DetailsProps {
user: SafeUser
memberPrice?: MemberPrice
}
export type JoinScandicFriendsCardProps = {
name: string
memberPrice?: MemberPrice
name?: string
}
export type RoomRate = {

View File

@@ -1,9 +1,7 @@
import type { CreditCard, SafeUser } from "@/types/user"
import type { CreditCard } from "@/types/user"
import type { PaymentMethodEnum } from "@/constants/booking"
import type { Child } from "../selectRate/selectRate"
export interface PaymentProps {
user: SafeUser
otherPaymentOptions: PaymentMethodEnum[]
mustBeGuaranteed: boolean
supportedCards: PaymentMethodEnum[]

View File

@@ -4,10 +4,11 @@ import type { Child, SelectRateSearchParams } from "./selectRate"
export interface RoomsContainerProps {
adultArray: number[]
booking: SelectRateSearchParams
bookingCode?: string
childArray?: Child[]
fromDate: Date
hotelId: number
toDate: Date
hotelData: HotelData | null
bookingCode?: string
hotelId: number
isUserLoggedIn: boolean
toDate: Date
}

View File

@@ -4,5 +4,4 @@ export interface SectionAccordionProps {
header: string
label: string
step: StepEnum
roomIndex: number
}

View File

@@ -16,7 +16,6 @@ export type RoomsData = {
export interface SummaryProps {
isMember: boolean
breakfastIncluded: boolean
}
export interface SummaryUIProps {
@@ -28,7 +27,6 @@ export interface SummaryUIProps {
}
export interface EnterDetailsSummaryProps extends SummaryUIProps {
breakfastIncluded: boolean
rooms: RoomState[]
}

View File

@@ -0,0 +1,20 @@
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details"
import type { StepEnum } from "@/types/enums/step"
import type { RoomState } from "@/types/stores/enter-details"
export interface RoomContextValue {
actions: {
setStep: (step: StepEnum) => void
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateDetails: (data: DetailsSchema) => void
}
currentStep: RoomState["currentStep"]
isComplete: RoomState["isComplete"]
isActiveRoom: boolean
room: RoomState["room"]
roomNr: number
steps: RoomState["steps"]
}

View File

@@ -0,0 +1,20 @@
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { Packages } from "@/types/requests/packages"
export interface Room {
bedTypes?: BedTypeSelection[]
breakfastIncluded?: boolean
cancellationText: string
mustBeGuaranteed?: boolean
packages: Packages | null
rateDetails: string[]
roomRate: RoomRate
roomType: string
roomTypeCode: string
}
export interface RoomProviderProps extends React.PropsWithChildren {
idx: number
room: Room
}

View File

@@ -1,15 +1,12 @@
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { StepEnum } from "@/types/enums/step"
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Room } from "@/types/providers/details/room"
import type { SafeUser } from "@/types/user"
import type { RoomData } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page"
import type { BreakfastPackages } from "../components/hotelReservation/enterDetails/breakfast"
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
import type { Packages } from "../requests/packages"
export interface DetailsProviderProps extends React.PropsWithChildren {
booking: SelectRateSearchParams
showBreakfastStep: boolean
roomsData: RoomData[]
breakfastPackages: BreakfastPackages | null
rooms: Room[]
searchParamsStr: string
user: SafeUser
vat: number

View File

@@ -1,7 +1,14 @@
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
BedTypeSchema,
BedTypeSelection,
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type {
BreakfastPackage,
BreakfastPackages,
} from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
DetailsSchema,
MultiroomDetailsSchema,
RoomPrice,
RoomRate,
SignedInDetailsSchema,
@@ -15,57 +22,71 @@ import type {
import type { Packages } from "../requests/packages"
export interface InitialRoomData {
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
bedTypes: BedTypeSelection[]
breakfastIncluded: boolean
cancellationText: string
rateDetails: string[] | undefined
roomFeatures: Packages | null
roomRate: RoomRate
roomType: string
rateDetails: string[] | undefined
cancellationText: string
roomFeatures: Packages | null
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
roomTypeCode: string
}
export interface RoomState extends InitialRoomData {
adults: number
childrenInRoom: Child[] | undefined
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
guest: DetailsSchema | SignedInDetailsSchema
roomPrice: RoomPrice
export interface RoomState {
currentStep: StepEnum | null
isComplete: boolean
room: InitialRoomData & {
adults: number
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
childrenInRoom: Child[] | undefined
guest: DetailsSchema | SignedInDetailsSchema
roomPrice: RoomPrice
}
steps: {
[StepEnum.selectBed]: RoomStep
[StepEnum.breakfast]?: RoomStep
[StepEnum.details]: RoomStep
}
}
export type InitialState = {
booking: SelectRateSearchParams
vat: number
rooms: InitialRoomData[]
breakfast?: false
vat: number
}
export interface DetailsState {
actions: {
setStep: (step: StepEnum | null, roomIndex?: number) => void
setStep: (idx: number) => (step: StepEnum) => void
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
setTotalPrice: (totalPrice: Price) => void
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateDetails: (data: DetailsSchema) => void
updateBedType: (idx: number) => (data: BedTypeSchema) => void
updateBreakfast: (idx: number) => (data: BreakfastPackage | false) => void
updateDetails: (idx: number) => (data: DetailsSchema) => void
updateMultiroomDetails: (
idx: number
) => (data: MultiroomDetailsSchema) => void
updateSeachParamString: (searchParamString: string) => void
}
activeRoom: number
booking: SelectRateSearchParams
breakfast: BreakfastPackage | false | undefined
breakfastPackages: BreakfastPackages | null
canProceedToPayment: boolean
isSubmittingDisabled: boolean
isSummaryOpen: boolean
isPriceDetailsModalOpen: boolean
lastRoom: number
rooms: RoomState[]
totalPrice: Price
searchParamString: string
totalPrice: Price
vat: number
bookingProgress: BookingProgress
}
export type PersistedState = {
activeRoom: number
booking: SelectRateSearchParams
bookingProgress: BookingProgress
rooms: RoomState[]
}
@@ -84,9 +105,3 @@ export type RoomStatus = {
[StepEnum.details]: RoomStep
}
}
export type BookingProgress = {
currentRoomIndex: number
roomStatuses: RoomStatus[]
canProceedToPayment: boolean
}

View File

@@ -45,6 +45,7 @@ export interface RatesState {
booking: SelectRateSearchParams
filterOptions: DefaultFilterOptions[]
hotelType: string | undefined
isUserLoggedIn: boolean
packages: NonNullable<Packages>
pathname: string
petRoomPackage: NonNullable<Packages>[number] | undefined
@@ -61,6 +62,7 @@ export interface InitialState
RatesState,
| "booking"
| "hotelType"
| "isUserLoggedIn"
| "packages"
| "pathname"
| "roomCategories"
@@ -68,7 +70,6 @@ export interface InitialState
| "searchParams"
| "vat"
> {
isUserLoggedIn: boolean
labels: {
accessibilityRoom: string
allergyRoom: string