Merged in feat/SW-1076-no-room-availability (pull request #1467)

Feat/SW-1076 no room availability

* fix: update booking error codes

* feat(SW-1076): handle no room availabilty on enter-details

* fix: parse to json in api mutation instead of expecting json

* fix: remove 'isComplete' state from sectionAccordion because it was not needed


Approved-by: Simon.Emanuelsson
This commit is contained in:
Tobias Johansson
2025-03-10 12:13:15 +00:00
parent 131cbfcda3
commit 7c233ab846
23 changed files with 139 additions and 63 deletions

View File

@@ -2,8 +2,8 @@ import { redirect } from "next/navigation"
import { import {
BOOKING_CONFIRMATION_NUMBER, BOOKING_CONFIRMATION_NUMBER,
BookingErrorCodeEnum,
MEMBERSHIP_FAILED_ERROR, MEMBERSHIP_FAILED_ERROR,
PaymentErrorCodeEnum,
} from "@/constants/booking" } from "@/constants/booking"
import { import {
bookingConfirmation, bookingConfirmation,
@@ -70,17 +70,17 @@ export default async function PaymentCallbackPage({
"errorCode", "errorCode",
error error
? error.errorCode.toString() ? error.errorCode.toString()
: PaymentErrorCodeEnum.Failed.toString() : BookingErrorCodeEnum.TransactionFailed
) )
} catch { } catch {
console.error( console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}` `[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
) )
if (status === "cancel") { if (status === "cancel") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString()) searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
} }
if (status === "error") { if (status === "error") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString()) searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}` errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
} }
} }

View File

@@ -1,6 +1,7 @@
import { notFound } from "next/navigation" import { notFound, redirect } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { selectRate } from "@/constants/routes/hotelReservation"
import { import {
getBreakfastPackages, getBreakfastPackages,
getHotel, getHotel,
@@ -16,13 +17,17 @@ import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop" import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile" import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils" import { generateChildrenString } from "@/components/HotelReservation/utils"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import RoomProvider from "@/providers/Details/RoomProvider" import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url" import { convertSearchParamsToObj } from "@/utils/url"
import styles from "./page.module.css" import styles from "./page.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
import type { Room } from "@/types/providers/details/room" import type { Room } from "@/types/providers/details/room"
@@ -81,7 +86,8 @@ export default async function DetailsPage({
) )
if (!roomAvailability) { if (!roomAvailability) {
continue // TODO: handle no room availability // redirect back to select-rate if availability call fails
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
} }
rooms.push({ rooms.push({
@@ -98,6 +104,8 @@ export default async function DetailsPage({
memberRate: roomAvailability?.memberRate, memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate, publicRate: roomAvailability.publicRate,
}, },
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
}) })
} }
@@ -139,8 +147,11 @@ export default async function DetailsPage({
// region: hotel?.address.city, // region: hotel?.address.city,
// } // }
const intl = await getIntl()
const firstRoom = rooms[0] const firstRoom = rooms[0]
const multirooms = rooms.slice(1) const multirooms = rooms.slice(1)
const isRoomNotAvailable = rooms.some((room) => !room.isAvailable)
return ( return (
<EnterDetailsProvider <EnterDetailsProvider
booking={booking} booking={booking}
@@ -153,6 +164,21 @@ export default async function DetailsPage({
<main> <main>
<HotelHeader hotelData={hotelData} /> <HotelHeader hotelData={hotelData} />
<div className={styles.container}> <div className={styles.container}>
{isRoomNotAvailable && (
<Alert
type={AlertTypeEnum.Alarm}
variant="inline"
heading={intl.formatMessage({ id: "Room sold out" })}
text={intl.formatMessage({
id: "Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
})}
link={{
title: intl.formatMessage({ id: "Change room" }),
url: `${selectRate(lang)}?${selectRoomParams.toString()}`,
keepSearchParams: true,
}}
/>
)}
<div className={styles.content}> <div className={styles.content}>
<RoomProvider idx={0} room={firstRoom}> <RoomProvider idx={0} room={firstRoom}>
<RoomOne user={user} /> <RoomOne user={user} />

View File

@@ -7,6 +7,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { import {
BookingErrorCodeEnum,
BookingStatusEnum, BookingStatusEnum,
PAYMENT_METHOD_TITLES, PAYMENT_METHOD_TITLES,
PaymentMethodEnum, PaymentMethodEnum,
@@ -107,6 +108,15 @@ export default function PaymentClient({
const initiateBooking = trpc.booking.create.useMutation({ const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
if (result) { if (result) {
if ("error" in result) {
if (result.cause === BookingErrorCodeEnum.AvailabilityError) {
window.location.reload() // reload to refetch room data because we dont know which room is unavailable
} else {
handlePaymentError(result.cause)
}
return
}
setBookingNumber(result.id) setBookingNumber(result.id)
const priceChange = result.rooms.find( const priceChange = result.rooms.find(

View File

@@ -25,14 +25,12 @@ export default function SectionAccordion({
actions: { setStep }, actions: { setStep },
currentStep, currentStep,
isActiveRoom, isActiveRoom,
room: { bedType, breakfast }, room: { bedType, breakfast, isAvailable },
steps, steps,
} = useRoomContext() } = useRoomContext()
const [isComplete, setIsComplete] = useState(false) const isStepComplete = !!(steps[step]?.isValid && isAvailable)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const isValid = steps[step]?.isValid ?? false
const [title, setTitle] = useState(label) const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" }) const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
@@ -54,14 +52,10 @@ export default function SectionAccordion({
} }
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle]) }, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
useEffect(() => {
setIsComplete(isValid)
}, [isValid, setIsComplete])
const accordionRef = useRef<HTMLDivElement>(null) const accordionRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const shouldBeOpen = currentStep === step && isActiveRoom const shouldBeOpen = currentStep === step && isActiveRoom && isAvailable
setIsOpen(shouldBeOpen) setIsOpen(shouldBeOpen)
// Scroll to this section when it is opened, // Scroll to this section when it is opened,
@@ -91,7 +85,7 @@ export default function SectionAccordion({
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, isActiveRoom, setIsOpen, step]) }, [currentStep, isActiveRoom, isAvailable, setIsOpen, step])
function goToStep() { function goToStep() {
setStep(step) setStep(step)
@@ -103,7 +97,7 @@ export default function SectionAccordion({
} }
const textColor = const textColor =
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled" isStepComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
return ( return (
<div <div
className={styles.accordion} className={styles.accordion}
@@ -112,8 +106,8 @@ export default function SectionAccordion({
ref={accordionRef} ref={accordionRef}
> >
<div className={styles.iconWrapper}> <div className={styles.iconWrapper}>
<div className={styles.circle} data-checked={isComplete}> <div className={styles.circle} data-checked={isStepComplete}>
{isComplete ? ( {isStepComplete ? (
<CheckIcon color="white" height="16" width="16" /> <CheckIcon color="white" height="16" width="16" />
) : null} ) : null}
</div> </div>
@@ -121,7 +115,7 @@ export default function SectionAccordion({
<header className={styles.header}> <header className={styles.header}>
<button <button
onClick={isOpen ? close : goToStep} onClick={isOpen ? close : goToStep}
disabled={!isComplete} disabled={!isStepComplete}
className={styles.modifyButton} className={styles.modifyButton}
> >
<Footnote <Footnote
@@ -136,7 +130,7 @@ export default function SectionAccordion({
<Subtitle className={styles.selection} type="two" color={textColor}> <Subtitle className={styles.selection} type="two" color={textColor}>
{title} {title}
</Subtitle> </Subtitle>
{isComplete && ( {isStepComplete && (
<ChevronDownIcon <ChevronDownIcon
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`} className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
color="burgundy" color="burgundy"

View File

@@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, EditIcon } from "@/components/Icons" import { CheckIcon, EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useRoomContext } from "@/contexts/Details/Room" import { useRoomContext } from "@/contexts/Details/Room"
@@ -39,7 +40,7 @@ export default function SelectedRoom() {
} }
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper} data-available={room.isAvailable}>
<div className={styles.iconWrapper}> <div className={styles.iconWrapper}>
<div className={styles.circle}> <div className={styles.circle}>
<CheckIcon color="white" height="16" width="16" /> <CheckIcon color="white" height="16" width="16" />
@@ -74,13 +75,15 @@ export default function SelectedRoom() {
</Subtitle> </Subtitle>
<Button <Button
variant="icon" variant="icon"
intent="text"
size="small" size="small"
color="burgundy"
onClick={changeRoom} onClick={changeRoom}
disabled={isPending} disabled={isPending}
> >
<EditIcon color="burgundy" /> <EditIcon color="burgundy" />
{intl.formatMessage({ id: "Change room" })} <Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Change room" })}
</Caption>
</Button> </Button>
</div> </div>
{room.roomTypeCode && ( {room.roomTypeCode && (

View File

@@ -5,6 +5,13 @@
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
} }
.wrapper[data-available="false"] .title,
.wrapper[data-available="false"] .description,
.wrapper[data-available="false"] .details {
opacity: 0.5;
pointer-events: none;
}
.main { .main {
width: 100%; width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
@@ -52,6 +59,10 @@
background-color: var(--UI-Input-Controls-Fill-Selected); background-color: var(--UI-Input-Controls-Fill-Selected);
} }
.wrapper[data-available="false"] .circle {
background-color: var(--Base-Surface-Subtle-Hover);
}
.rate { .rate {
color: var(--UI-Text-Placeholder); color: var(--UI-Text-Placeholder);
display: block; display: block;

View File

@@ -381,7 +381,7 @@ export default function SummaryUI({
totalPrice.local.currency totalPrice.local.currency
)} )}
</Body> </Body>
{totalPrice.local.regularPrice && ( {totalPrice.local.regularPrice ? (
<Caption color="uiTextMediumContrast" striked={true}> <Caption color="uiTextMediumContrast" striked={true}>
{formatPrice( {formatPrice(
intl, intl,
@@ -389,7 +389,7 @@ export default function SummaryUI({
totalPrice.local.currency totalPrice.local.currency
)} )}
</Caption> </Caption>
)} ) : null}
{totalPrice.requested && ( {totalPrice.requested && (
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{intl.formatMessage( {intl.formatMessage(

View File

@@ -63,6 +63,7 @@ const rooms: RoomState[] = [
roomRate: roomRate, roomRate: roomRate,
roomType: "Standard", roomType: "Standard",
roomTypeCode: "QS", roomTypeCode: "QS",
isAvailable: true,
}, },
steps: { steps: {
[StepEnum.selectBed]: { [StepEnum.selectBed]: {
@@ -100,6 +101,7 @@ const rooms: RoomState[] = [
roomRate: roomRate, roomRate: roomRate,
roomType: "Standard", roomType: "Standard",
roomTypeCode: "QS", roomTypeCode: "QS",
isAvailable: true,
}, },
steps: { steps: {
[StepEnum.selectBed]: { [StepEnum.selectBed]: {

View File

@@ -64,10 +64,16 @@ export enum PaymentMethodEnum {
discover = "discover", discover = "discover",
} }
export enum PaymentErrorCodeEnum { export enum BookingErrorCodeEnum {
Abandoned = 5, InternalError = "InternalError",
Cancelled = 6, ReservationError = "ReservationError",
Failed = 7, AvailabilityError = "AvailabilityError",
BookingStatusNotFound = "BookingStatusNotFound",
TransactionAbandoned = "TransactionAbandoned",
TransactionCancelled = "TransactionCancelled",
TransactionFailed = "TransactionFailed",
BookingStateError = "BookingStateError",
MembershipFailedError = "MembershipFailedError",
} }
export const PAYMENT_METHOD_TITLES: Record< export const PAYMENT_METHOD_TITLES: Record<

View File

@@ -4,7 +4,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react" import { useCallback, useEffect } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { PaymentErrorCodeEnum } from "@/constants/booking" import { BookingErrorCodeEnum } from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details" import { useEnterDetailsStore } from "@/stores/enter-details"
import { toast } from "@/components/TempDesignSystem/Toasts" import { toast } from "@/components/TempDesignSystem/Toasts"
@@ -19,9 +19,9 @@ export function usePaymentFailedToast() {
const router = useRouter() const router = useRouter()
const getErrorMessage = useCallback( const getErrorMessage = useCallback(
(errorCode: PaymentErrorCodeEnum) => { (errorCode: string | null) => {
switch (errorCode) { switch (errorCode) {
case PaymentErrorCodeEnum.Cancelled: case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({ return intl.formatMessage({
id: "You have now cancelled your payment.", id: "You have now cancelled your payment.",
}) })
@@ -34,8 +34,7 @@ export function usePaymentFailedToast() {
[intl] [intl]
) )
const errorCodeString = searchParams.get("errorCode") const errorCode = searchParams.get("errorCode")
const errorCode = Number(errorCodeString) as PaymentErrorCodeEnum
const errorMessage = getErrorMessage(errorCode) const errorMessage = getErrorMessage(errorCode)
useEffect(() => { useEffect(() => {
@@ -44,7 +43,9 @@ export function usePaymentFailedToast() {
// setTimeout is needed to show toasts on page load: https://sonner.emilkowal.ski/toast#render-toast-on-page-load // setTimeout is needed to show toasts on page load: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
setTimeout(() => { setTimeout(() => {
const toastType = const toastType =
errorCode === PaymentErrorCodeEnum.Cancelled ? "warning" : "error" errorCode === BookingErrorCodeEnum.TransactionCancelled
? "warning"
: "error"
toast[toastType](errorMessage) toast[toastType](errorMessage)
}) })

View File

@@ -575,6 +575,7 @@
"Room charge": "Værelsesafgift", "Room charge": "Værelsesafgift",
"Room details": "Room details", "Room details": "Room details",
"Room facilities": "Værelsesfaciliteter", "Room facilities": "Værelsesfaciliteter",
"Room sold out": "Værelse solgt ud",
"Room total": "Værelse total", "Room total": "Værelse total",
"Room {roomIndex}": "Værelse {roomIndex}", "Room {roomIndex}": "Værelse {roomIndex}",
"Rooms": "Værelser", "Rooms": "Værelser",
@@ -686,6 +687,7 @@
"Type of bed": "Sengtype", "Type of bed": "Sengtype",
"Type of room": "Værelsestype", "Type of room": "Værelsestype",
"U-shape": "U-form", "U-shape": "U-form",
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.": "Desværre er et af de værelser, du har valgt, solgt ud. Vælg et andet værelse for at fortsætte.",
"Unlink accounts": "Unlink accounts", "Unlink accounts": "Unlink accounts",
"Unpaid": "Ikke betalt", "Unpaid": "Ikke betalt",
"Until {time}, {date}": "Indtil {time} den {date}", "Until {time}, {date}": "Indtil {time} den {date}",

View File

@@ -574,6 +574,7 @@
"Room charge": "Zimmerpreis", "Room charge": "Zimmerpreis",
"Room details": "Room details", "Room details": "Room details",
"Room facilities": "Zimmerausstattung", "Room facilities": "Zimmerausstattung",
"Room sold out": "Zimmer verkauft",
"Room total": "Zimmer total", "Room total": "Zimmer total",
"Room {roomIndex}": "Zimmer {roomIndex}", "Room {roomIndex}": "Zimmer {roomIndex}",
"Rooms": "Räume", "Rooms": "Räume",
@@ -684,6 +685,7 @@
"Type of bed": "Bettentyp", "Type of bed": "Bettentyp",
"Type of room": "Zimmerart", "Type of room": "Zimmerart",
"U-shape": "U-shape", "U-shape": "U-shape",
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.": "Leider ist eines der von Ihnen ausgewählten Zimmer verkauft. Bitte wählen Sie ein anderes Zimmer, um fortzufahren.",
"Unlink accounts": "Unlink accounts", "Unlink accounts": "Unlink accounts",
"Unpaid": "Nicht bezahlt", "Unpaid": "Nicht bezahlt",
"Until {time}, {date}": "Bis {time} am {date}", "Until {time}, {date}": "Bis {time} am {date}",

View File

@@ -581,6 +581,7 @@
"Room charge": "Room charge", "Room charge": "Room charge",
"Room details": "Room details", "Room details": "Room details",
"Room facilities": "Room facilities", "Room facilities": "Room facilities",
"Room sold out": "Room sold out",
"Room total": "Room total", "Room total": "Room total",
"Room {roomIndex}": "Room {roomIndex}", "Room {roomIndex}": "Room {roomIndex}",
"Rooms": "Rooms", "Rooms": "Rooms",
@@ -692,6 +693,7 @@
"Type of bed": "Type of bed", "Type of bed": "Type of bed",
"Type of room": "Type of room", "Type of room": "Type of room",
"U-shape": "U-shape", "U-shape": "U-shape",
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.": "Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
"Unlink accounts": "Unlink accounts", "Unlink accounts": "Unlink accounts",
"Unpaid": "Unpaid", "Unpaid": "Unpaid",
"Until {time}, {date}": "Until {time}, {date}", "Until {time}, {date}": "Until {time}, {date}",

View File

@@ -573,6 +573,7 @@
"Room charge": "Huonemaksu", "Room charge": "Huonemaksu",
"Room details": "Room details", "Room details": "Room details",
"Room facilities": "Huoneen varustelu", "Room facilities": "Huoneen varustelu",
"Room sold out": "Huone slutsattu",
"Room total": "Huoneen kokonaishinta", "Room total": "Huoneen kokonaishinta",
"Room {roomIndex}": "Huone {roomIndex}", "Room {roomIndex}": "Huone {roomIndex}",
"Rooms": "Huoneet", "Rooms": "Huoneet",
@@ -684,6 +685,7 @@
"Type of bed": "Vuodetyyppi", "Type of bed": "Vuodetyyppi",
"Type of room": "Huonetyyppi", "Type of room": "Huonetyyppi",
"U-shape": "U-muoto", "U-shape": "U-muoto",
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.": "Valitettavasti valitsemasi huone on loppuunmyyty. Valitse toinen huone jatkaaksesi.",
"Unlink accounts": "Unlink accounts", "Unlink accounts": "Unlink accounts",
"Unpaid": "Maksettaa", "Unpaid": "Maksettaa",
"Until {time}, {date}": "Asti {time}, {date}", "Until {time}, {date}": "Asti {time}, {date}",

View File

@@ -572,6 +572,7 @@
"Room charge": "Rumspris", "Room charge": "Rumspris",
"Room details": "Room details", "Room details": "Room details",
"Room facilities": "Rumfaciliteter", "Room facilities": "Rumfaciliteter",
"Room sold out": "Rum slutsålt",
"Room total": "Rum total", "Room total": "Rum total",
"Room {roomIndex}": "Rum {roomIndex}", "Room {roomIndex}": "Rum {roomIndex}",
"Rooms": "Rum", "Rooms": "Rum",
@@ -682,6 +683,7 @@
"Type of bed": "Sängtyp", "Type of bed": "Sängtyp",
"Type of room": "Rumstyp", "Type of room": "Rumstyp",
"U-shape": "U-form", "U-shape": "U-form",
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.": "Tyvärr, ett av de rum du valde är slutsålt. Vänligen välj ett annat rum för att fortsätta.",
"Unlink accounts": "Unlink accounts", "Unlink accounts": "Unlink accounts",
"Unpaid": "Ej betalt", "Unpaid": "Ej betalt",
"Until {time}, {date}": "Tills {time} den {date}", "Until {time}, {date}": "Tills {time} den {date}",

View File

@@ -90,6 +90,7 @@
"react-international-phone": "^4.2.6", "react-international-phone": "^4.2.6",
"react-intl": "^6.6.8", "react-intl": "^6.6.8",
"react-to-print": "^3.0.2", "react-to-print": "^3.0.2",
"secure-json-parse": "^4.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.7.0", "sonner": "^1.7.0",

View File

@@ -37,6 +37,7 @@ export default function EnterDetailsProvider({
rooms: rooms rooms: rooms
.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,
breakfastIncluded: !!room.breakfastIncluded, breakfastIncluded: !!room.breakfastIncluded,
cancellationText: room.cancellationText, cancellationText: room.cancellationText,
rateDetails: room.rateDetails, rateDetails: room.rateDetails,
@@ -86,6 +87,10 @@ export default function EnterDetailsProvider({
// since store is readonly // since store is readonly
const currentRoom = deepmerge({}, store.rooms[idx]) const currentRoom = deepmerge({}, store.rooms[idx])
if (!currentRoom.room.isAvailable) {
return currentRoom
}
if (!currentRoom.room.bedType && storedRoom.room.bedType) { if (!currentRoom.room.bedType && storedRoom.room.bedType) {
const sameBed = currentRoom.room.bedTypes.find( const sameBed = currentRoom.room.bedTypes.find(
(bedType) => bedType.value === storedRoom.room.bedType?.roomTypeCode (bedType) => bedType.value === storedRoom.room.bedType?.roomTypeCode
@@ -134,7 +139,9 @@ export default function EnterDetailsProvider({
return currentRoom return currentRoom
}) })
const canProceedToPayment = updatedRooms.every((room) => room.isComplete) const canProceedToPayment = updatedRooms.every(
(room) => room.isComplete && room.room.isAvailable
)
const nights = dt(booking.toDate).diff(booking.fromDate, "days") const nights = dt(booking.toDate).diff(booking.fromDate, "days")
const currency = (updatedRooms[0].room.roomRate.publicRate?.localPrice const currency = (updatedRooms[0].room.roomRate.publicRate?.localPrice

View File

@@ -1,4 +1,5 @@
import { metrics } from "@opentelemetry/api" import { metrics } from "@opentelemetry/api"
import sjson from "secure-json-parse"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { getVerifiedUser } from "@/server/routers/user/query" import { getVerifiedUser } from "@/server/routers/user/query"
@@ -11,8 +12,8 @@ import {
cancelBookingInput, cancelBookingInput,
createBookingInput, createBookingInput,
priceChangeInput, priceChangeInput,
updateBookingInput,
removePackageInput, removePackageInput,
updateBookingInput,
} from "./input" } from "./input"
import { createBookingSchema } from "./output" import { createBookingSchema } from "./output"
@@ -135,10 +136,17 @@ export const bookingMutationRouter = router({
error: { error: {
status: apiResponse.status, status: apiResponse.status,
statusText: apiResponse.statusText, statusText: apiResponse.statusText,
error: text, text,
}, },
}) })
) )
const apiJson = sjson.safeParse(text)
if ("errors" in apiJson && apiJson.errors.length) {
const error = apiJson.errors[0]
return { error: true, cause: error.code } as const
}
return null return null
} }

View File

@@ -64,7 +64,6 @@ import {
} from "./utils" } from "./utils"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
@@ -595,7 +594,6 @@ export const hotelQueryRouter = router({
bookingCode, bookingCode,
rateCode, rateCode,
roomTypeCode, roomTypeCode,
packageCodes,
} = input } = input
const params: Record<string, string | number | undefined> = { const params: Record<string, string | number | undefined> = {
@@ -691,27 +689,11 @@ export const hotelQueryRouter = router({
ctx.serviceToken ctx.serviceToken
) )
const availableRooms = const rooms = validateAvailabilityData.data.roomConfigurations
validateAvailabilityData.data.roomConfigurations.filter((room) => { const selectedRoom = rooms.find(
if (packageCodes) {
return (
room.status === AvailabilityEnum.Available &&
room.features.some(
(feature) =>
packageCodes.includes(feature.code) && feature.inventory > 0
)
)
}
return room.status === AvailabilityEnum.Available
})
const selectedRoom = availableRooms.find(
(room) => room.roomTypeCode === roomTypeCode (room) => room.roomTypeCode === roomTypeCode
) )
const availableRoomsInCategory = availableRooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
if (!selectedRoom) { if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, { metrics.selectedRoomAvailability.fail.add(1, {
hotelId, hotelId,
@@ -720,6 +702,7 @@ export const hotelQueryRouter = router({
adults, adults,
children, children,
bookingCode, bookingCode,
roomTypeCode,
error_type: "not_found", error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`, error: `Couldn't find selected room with input: ${roomTypeCode}`,
}) })
@@ -727,6 +710,10 @@ export const hotelQueryRouter = router({
return null return null
} }
const availableRoomsInCategory = rooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
const rateTypes = selectedRoom.products.find( const rateTypes = selectedRoom.products.find(
(rate) => (rate) =>
rate.public?.rateCode === rateCode || rate.public?.rateCode === rateCode ||

View File

@@ -26,15 +26,15 @@ export function extractGuestFromUser(user: NonNullable<SafeUser>) {
} }
export function checkIsSameBooking( export function checkIsSameBooking(
prev: SelectRateSearchParams, prev: SelectRateSearchParams & { errorCode?: string },
next: SelectRateSearchParams next: SelectRateSearchParams & { errorCode?: string }
) { ) {
const { rooms: prevRooms, ...prevBooking } = prev const { rooms: prevRooms, errorCode: prevErrorCode, ...prevBooking } = prev
const prevRoomsWithoutRateCodes = prevRooms.map( const prevRoomsWithoutRateCodes = prevRooms.map(
({ rateCode, counterRateCode, roomTypeCode, ...room }) => room ({ rateCode, counterRateCode, roomTypeCode, ...room }) => room
) )
const { rooms: nextRooms, ...nextBooking } = next const { rooms: nextRooms, errorCode: nextErrorCode, ...nextBooking } = next
const nextRoomsWithoutRateCodes = nextRooms.map( const nextRoomsWithoutRateCodes = nextRooms.map(
({ rateCode, counterRateCode, roomTypeCode, ...room }) => room ({ rateCode, counterRateCode, roomTypeCode, ...room }) => room

View File

@@ -13,6 +13,7 @@ export interface Room {
roomRate: RoomRate roomRate: RoomRate
roomType: string roomType: string
roomTypeCode: string roomTypeCode: string
isAvailable: boolean
} }
export interface RoomProviderProps extends React.PropsWithChildren { export interface RoomProviderProps extends React.PropsWithChildren {

View File

@@ -22,6 +22,7 @@ import type {
import type { Packages } from "../requests/packages" import type { Packages } from "../requests/packages"
export interface InitialRoomData { export interface InitialRoomData {
isAvailable: boolean
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
bedTypes: BedTypeSelection[] bedTypes: BedTypeSelection[]
breakfastIncluded: boolean breakfastIncluded: boolean

View File

@@ -6147,6 +6147,7 @@ __metadata:
react-intl: "npm:^6.6.8" react-intl: "npm:^6.6.8"
react-to-print: "npm:^3.0.2" react-to-print: "npm:^3.0.2"
schema-dts: "npm:^1.1.2" schema-dts: "npm:^1.1.2"
secure-json-parse: "npm:^4.0.0"
server-only: "npm:^0.0.1" server-only: "npm:^0.0.1"
slugify: "npm:^1.6.6" slugify: "npm:^1.6.6"
sonner: "npm:^1.7.0" sonner: "npm:^1.7.0"
@@ -18487,6 +18488,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"secure-json-parse@npm:^4.0.0":
version: 4.0.0
resolution: "secure-json-parse@npm:4.0.0"
checksum: 10c0/1a298cf00e1de91e833cee5eb406d6e77fb2f7eca9bef3902047d49e7f5d3e6c21b5de61ff73466c831e716430bfe87d732a6e645a7dabb5f1e8a8e4d3e15eb4
languageName: node
linkType: hard
"semver@npm:^5.6.0": "semver@npm:^5.6.0":
version: 5.7.2 version: 5.7.2
resolution: "semver@npm:5.7.2" resolution: "semver@npm:5.7.2"