feat: keep inventory of bedselections

This commit is contained in:
Simon Emanuelsson
2025-05-16 16:58:53 +02:00
committed by Michael Zetterberg
parent 39b89c5d51
commit 5ca30d02a0
12 changed files with 153 additions and 47 deletions

View File

@@ -1,12 +1,8 @@
import { cookies } from "next/headers" import { cookies } from "next/headers"
import { notFound, redirect } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { import { FamilyAndFriendsCodes } from "@/constants/booking"
BookingErrorCodeEnum,
FamilyAndFriendsCodes,
} from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation"
import { import {
getBreakfastPackages, getBreakfastPackages,
getHotel, getHotel,
@@ -30,7 +26,6 @@ import styles from "./page.module.css"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
import type { Room } from "@/types/providers/details/room"
export default async function DetailsPage({ export default async function DetailsPage({
params: { lang }, params: { lang },
@@ -76,25 +71,11 @@ export default async function DetailsPage({
void getBreakfastPackages(breakfastInput) void getBreakfastPackages(breakfastInput)
void getProfileSafely() void getProfileSafely()
const roomsAvailability = await getSelectedRoomsAvailabilityEnterDetails({ const rooms = await getSelectedRoomsAvailabilityEnterDetails({
booking, booking,
lang, lang,
}) })
const rooms: Room[] = []
for (let room of roomsAvailability) {
if (!room) {
// TODO: This could be done in the route already.
// (possibly also add an error case to url?)
// -------------------------------------------------------
// redirect back to select-rate if availability call fails
selectRoomParams.set("errorCode", BookingErrorCodeEnum.AvailabilityError)
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
}
rooms.push(room)
}
const hotelData = await getHotel(hotelInput) const hotelData = await getHotel(hotelInput)
if (!hotelData || !rooms.length) { if (!hotelData || !rooms.length) {

View File

@@ -9,6 +9,7 @@ import {
type BedTypeEnum, type BedTypeEnum,
type ExtraBedTypeEnum, type ExtraBedTypeEnum,
} from "@/constants/booking" } from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import RadioCard from "@/components/TempDesignSystem/Form/RadioCard" import RadioCard from "@/components/TempDesignSystem/Form/RadioCard"
import { useRoomContext } from "@/contexts/Details/Room" import { useRoomContext } from "@/contexts/Details/Room"
@@ -23,6 +24,7 @@ import type { IconProps } from "@scandic-hotels/design-system/Icons"
import type { BedTypeFormSchema } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BedTypeFormSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType() { export default function BedType() {
const availableBeds = useEnterDetailsStore((state) => state.availableBeds)
const { const {
actions: { updateBedType }, actions: { updateBedType },
room: { bedType, bedTypes }, room: { bedType, bedTypes },
@@ -79,6 +81,11 @@ export default function BedType() {
roomType.size.max === roomType.size.min roomType.size.max === roomType.size.min
? `${roomType.size.min} cm` ? `${roomType.size.min} cm`
: `${roomType.size.min} cm - ${roomType.size.max} cm` : `${roomType.size.min} cm - ${roomType.size.max} cm`
const bedAvailable = availableBeds[roomType.value]
// This is needed since otherwise, picking the last room would make
// the card disabled
const isSameBedAsSelected = bedType?.roomTypeCode === roomType.value
return ( return (
<RadioCard <RadioCard
key={roomType.value} key={roomType.value}
@@ -89,6 +96,7 @@ export default function BedType() {
props={props} props={props}
/> />
)} )}
disabled={!bedAvailable && !isSameBedAsSelected}
id={roomType.value} id={roomType.value}
name="bedType" name="bedType"
subtitle={width} subtitle={width}

View File

@@ -20,10 +20,9 @@ import { StepEnum } from "@/types/enums/step"
export default function Multiroom() { export default function Multiroom() {
const intl = useIntl() const intl = useIntl()
const { room, roomNr } = useRoomContext() const { room, roomNr } = useRoomContext()
const { breakfastPackages } = useEnterDetailsStore((state) => ({ const breakfastPackages = useEnterDetailsStore(
breakfastPackages: state.breakfastPackages, (state) => state.breakfastPackages
rooms: state.rooms, )
}))
const showBreakfastStep = const showBreakfastStep =
!room.breakfastIncluded && !!breakfastPackages.length !room.breakfastIncluded && !!breakfastPackages.length
@@ -55,7 +54,7 @@ export default function Multiroom() {
<SelectedRoom /> <SelectedRoom />
{room.bedTypes ? ( {room.bedTypes.length ? (
<Section <Section
header={intl.formatMessage({ defaultMessage: "Bed preference" })} header={intl.formatMessage({ defaultMessage: "Bed preference" })}
label={intl.formatMessage({ defaultMessage: "Preferred bed type" })} label={intl.formatMessage({ defaultMessage: "Preferred bed type" })}

View File

@@ -23,15 +23,13 @@ export default function AvailabilityError() {
}) })
useEffect(() => { useEffect(() => {
if (!hasAvailabilityError) { if (hasAvailabilityError) {
return toast.error(errorMessage)
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`)
} }
toast.error(errorMessage)
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`)
}, [errorMessage, hasAvailabilityError, pathname, searchParams]) }, [errorMessage, hasAvailabilityError, pathname, searchParams])
return null return null

View File

@@ -1,3 +1,5 @@
import { redirect } from "next/navigation"
import { isDefined } from "@/server/utils" import { isDefined } from "@/server/utils"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
@@ -365,6 +367,12 @@ export const getSelectedRoomsAvailabilityEnterDetails = cache(
async function getMemoizedSelectedRoomsAvailability( async function getMemoizedSelectedRoomsAvailability(
input: RoomsAvailabilityExtendedInputSchema input: RoomsAvailabilityExtendedInputSchema
) { ) {
return serverClient().hotel.availability.enterDetails(input) const result = await serverClient().hotel.availability.enterDetails(input)
if (typeof result === "string") {
redirect(result)
}
return result
} }
) )

View File

@@ -39,7 +39,7 @@ export default function EnterDetailsProvider({
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes? .filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
.map((room) => ({ .map((room) => ({
isAvailable: room.isAvailable, isAvailable: room.isAvailable,
breakfastIncluded: !!room.breakfastIncluded, breakfastIncluded: room.breakfastIncluded,
cancellationText: room.cancellationText, cancellationText: room.cancellationText,
rateDetails: room.rateDetails, rateDetails: room.rateDetails,
memberRateDetails: room.memberRateDetails, memberRateDetails: room.memberRateDetails,
@@ -48,7 +48,7 @@ export default function EnterDetailsProvider({
roomRate: room.roomRate, roomRate: room.roomRate,
roomType: room.roomType, roomType: room.roomType,
roomTypeCode: room.roomTypeCode, roomTypeCode: room.roomTypeCode,
bedTypes: room.bedTypes!, bedTypes: room.bedTypes,
bedType: bedType:
room.bedTypes?.length === 1 room.bedTypes?.length === 1
? { ? {
@@ -186,12 +186,25 @@ export default function EnterDetailsProvider({
nights nights
) )
// Need to create a deep new copy since store is readonly
const availableBeds = deepmerge({}, store.availableBeds)
for (const filteredOutMissingRoom of filteredOutMissingRooms) {
if (filteredOutMissingRoom.room.bedType) {
const roomTypeCode = filteredOutMissingRoom.room.bedType.roomTypeCode
availableBeds[roomTypeCode] = Math.max(
availableBeds[roomTypeCode] - 1,
0
)
}
}
writeToSessionStorage({ writeToSessionStorage({
booking, booking,
rooms: filteredOutMissingRooms, rooms: filteredOutMissingRooms,
}) })
storeRef.current?.setState({ storeRef.current?.setState({
availableBeds,
canProceedToPayment, canProceedToPayment,
rooms: filteredOutMissingRooms, rooms: filteredOutMissingRooms,
totalPrice, totalPrice,

View File

@@ -64,6 +64,7 @@ import {
getRoomsAvailability, getRoomsAvailability,
getSelectedRoomAvailability, getSelectedRoomAvailability,
mergeRoomTypes, mergeRoomTypes,
selectRateRedirectURL,
} from "./utils" } from "./utils"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
@@ -71,6 +72,7 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { RateEnum } from "@/types/enums/rate" import { RateEnum } from "@/types/enums/rate"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel" import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel"
import type { Room } from "@/types/providers/details/room"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations" import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
@@ -252,7 +254,38 @@ export const hotelQueryRouter = router({
}) })
} }
return selectedRooms const totalBedsAvailableForRoomTypeCode: Record<string, number> = {}
for (const selectedRoom of selectedRooms) {
if (selectedRoom) {
if (!totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]) {
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] =
selectedRoom.bedTypes.reduce(
(total, bedType) => total + bedType.roomsLeft,
0
)
}
}
}
for (const [idx, selectedRoom] of selectedRooms.entries()) {
if (selectedRoom) {
const totalBedsLeft =
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]
if (totalBedsLeft <= 0) {
selectedRooms[idx] = null
continue
}
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] =
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] - 1
}
}
if (selectedRooms.some((sr) => !sr)) {
return selectRateRedirectURL(input, selectedRooms.map(Boolean))
}
// Make TS show appropriate type
return selectedRooms.filter((sr): sr is Room => !!sr)
}), }),
myStay: safeProtectedServiceProcedure myStay: safeProtectedServiceProcedure
.input(myStayRoomAvailabilityInputSchema) .input(myStayRoomAvailabilityInputSchema)

View File

@@ -1,8 +1,9 @@
import deepmerge from "deepmerge" import deepmerge from "deepmerge"
import stringify from "json-stable-stringify-without-jsonify" import stringify from "json-stable-stringify-without-jsonify"
import { REDEMPTION } from "@/constants/booking" import { BookingErrorCodeEnum, REDEMPTION } from "@/constants/booking"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { badRequestError } from "@/server/errors/trpc" import { badRequestError } from "@/server/errors/trpc"
@@ -43,6 +44,7 @@ import type { PackagesOutput } from "@/types/requests/packages"
import type { import type {
HotelsAvailabilityInputSchema, HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema,
RoomsAvailabilityExtendedInputSchema,
RoomsAvailabilityInputRoom, RoomsAvailabilityInputRoom,
RoomsAvailabilityOutputSchema, RoomsAvailabilityOutputSchema,
} from "@/types/trpc/routers/hotel/availability" } from "@/types/trpc/routers/hotel/availability"
@@ -1245,6 +1247,7 @@ export function getBedTypes(
size: matchingRoom.mainBed.widthRange, size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code, value: matchingRoom.code,
type: matchingRoom.mainBed.type, type: matchingRoom.mainBed.type,
roomsLeft: availRoom.roomsLeft,
extraBed: matchingRoom.fixedExtraBed extraBed: matchingRoom.fixedExtraBed
? { ? {
type: matchingRoom.fixedExtraBed.type, type: matchingRoom.fixedExtraBed.type,
@@ -1293,3 +1296,43 @@ export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) {
} }
return Array.from(roomConfigs.values()) return Array.from(roomConfigs.values())
} }
export function selectRateRedirectURL(
input: RoomsAvailabilityExtendedInputSchema,
selectedRooms: boolean[]
) {
const searchParams = new URLSearchParams({
errorCode: BookingErrorCodeEnum.AvailabilityError,
fromdate: input.booking.fromDate,
hotel: input.booking.hotelId,
todate: input.booking.toDate,
})
if (input.booking.searchType) {
searchParams.set("searchtype", input.booking.searchType)
}
for (const [idx, room] of input.booking.rooms.entries()) {
searchParams.set(`room[${idx}].adults`, room.adults.toString())
if (selectedRooms[idx]) {
if (room.counterRateCode) {
searchParams.set(`room[${idx}].counterratecode`, room.counterRateCode)
}
searchParams.set(`room[${idx}].ratecode`, room.rateCode)
searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode)
}
if (room.bookingCode) {
searchParams.set(`room[${idx}].bookingCode`, room.bookingCode)
}
if (room.packages) {
searchParams.set(`room[${idx}].packages`, room.packages.join(","))
}
if (room.childrenInRoom?.length) {
for (const [i, kid] of room.childrenInRoom.entries()) {
searchParams.set(`room[${idx}].child[${i}].age`, kid.age.toString())
searchParams.set(`room[${idx}].child[${i}].bed`, kid.bed.toString())
}
}
}
return `${selectRate(input.lang)}?${searchParams.toString()}`
}

View File

@@ -139,7 +139,19 @@ export function createDetailsStore(
} }
}) })
const availableBeds = initialState.rooms.reduce<
DetailsState["availableBeds"]
>((total, room) => {
for (const bed of room.bedTypes) {
if (!total[bed.value]) {
total[bed.value] = bed.roomsLeft
}
}
return total
}, {})
return create<DetailsState>()((set) => ({ return create<DetailsState>()((set) => ({
availableBeds,
booking: initialState.booking, booking: initialState.booking,
breakfastPackages, breakfastPackages,
canProceedToPayment: false, canProceedToPayment: false,
@@ -179,6 +191,15 @@ export function createDetailsStore(
updateBedType(bedType) { updateBedType(bedType) {
return set( return set(
produce((state: DetailsState) => { produce((state: DetailsState) => {
const currentlySelectedBed =
state.rooms[idx].room.bedType?.roomTypeCode
if (currentlySelectedBed) {
state.availableBeds[currentlySelectedBed] =
state.availableBeds[currentlySelectedBed] + 1
}
state.availableBeds[bedType.roomTypeCode] =
state.availableBeds[bedType.roomTypeCode] - 1
state.rooms[idx].steps[StepEnum.selectBed].isValid = true state.rooms[idx].steps[StepEnum.selectBed].isValid = true
state.rooms[idx].room.bedType = bedType state.rooms[idx].room.bedType = bedType

View File

@@ -14,6 +14,7 @@ export type BedTypeSelection = {
} }
value: string value: string
type: BedTypeEnum type: BedTypeEnum
roomsLeft: number
extraBed: extraBed:
| { | {
description: string description: string

View File

@@ -1,21 +1,21 @@
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details" import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { RateEnum } from "@/types/enums/rate" import type { RateEnum } from "@/types/enums/rate"
import type { Packages } from "@/types/requests/packages" import type { Package } from "@/types/requests/packages"
export interface Room { export interface Room {
bedTypes?: BedTypeSelection[] bedTypes: BedTypeSelection[]
breakfastIncluded?: boolean breakfastIncluded: boolean
cancellationRule?: string cancellationRule?: string
cancellationText: string cancellationText: string
mustBeGuaranteed: boolean mustBeGuaranteed: boolean
memberMustBeGuaranteed?: boolean memberMustBeGuaranteed: boolean | undefined
packages: Packages | null packages: Package[]
rate: RateEnum rate: RateEnum
rateDefinitionTitle: string rateDefinitionTitle: string
rateDetails: string[] rateDetails: string[]
memberRateDetails?: string[] memberRateDetails: string[] | undefined
rateTitle?: string rateTitle: string | undefined
rateType: string rateType: string
roomRate: RoomRate roomRate: RoomRate
roomType: string roomType: string

View File

@@ -90,6 +90,7 @@ export interface DetailsState {
updateSeachParamString: (searchParamString: string) => void updateSeachParamString: (searchParamString: string) => void
addPreSubmitCallback: (name: string, callback: () => void) => void addPreSubmitCallback: (name: string, callback: () => void) => void
} }
availableBeds: Record<string, number>
booking: SelectRateSearchParams booking: SelectRateSearchParams
breakfastPackages: BreakfastPackages breakfastPackages: BreakfastPackages
canProceedToPayment: boolean canProceedToPayment: boolean