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 {
BOOKING_CONFIRMATION_NUMBER,
BookingErrorCodeEnum,
MEMBERSHIP_FAILED_ERROR,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import {
bookingConfirmation,
@@ -70,17 +70,17 @@ export default async function PaymentCallbackPage({
"errorCode",
error
? error.errorCode.toString()
: PaymentErrorCodeEnum.Failed.toString()
: BookingErrorCodeEnum.TransactionFailed
)
} catch {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString())
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
}
if (status === "error") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString())
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
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 { selectRate } from "@/constants/routes/hotelReservation"
import {
getBreakfastPackages,
getHotel,
@@ -16,13 +17,17 @@ 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 Alert from "@/components/TempDesignSystem/Alert"
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 { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { LangParams, PageArgs } from "@/types/params"
import type { Room } from "@/types/providers/details/room"
@@ -81,7 +86,8 @@ export default async function DetailsPage({
)
if (!roomAvailability) {
continue // TODO: handle no room availability
// redirect back to select-rate if availability call fails
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
}
rooms.push({
@@ -98,6 +104,8 @@ export default async function DetailsPage({
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
},
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
})
}
@@ -139,8 +147,11 @@ export default async function DetailsPage({
// region: hotel?.address.city,
// }
const intl = await getIntl()
const firstRoom = rooms[0]
const multirooms = rooms.slice(1)
const isRoomNotAvailable = rooms.some((room) => !room.isAvailable)
return (
<EnterDetailsProvider
booking={booking}
@@ -153,6 +164,21 @@ export default async function DetailsPage({
<main>
<HotelHeader hotelData={hotelData} />
<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}>
<RoomProvider idx={0} room={firstRoom}>
<RoomOne user={user} />

View File

@@ -7,6 +7,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import {
BookingErrorCodeEnum,
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
@@ -107,6 +108,15 @@ export default function PaymentClient({
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (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)
const priceChange = result.rooms.find(

View File

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

View File

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

View File

@@ -5,6 +5,13 @@
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 {
width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
@@ -52,6 +59,10 @@
background-color: var(--UI-Input-Controls-Fill-Selected);
}
.wrapper[data-available="false"] .circle {
background-color: var(--Base-Surface-Subtle-Hover);
}
.rate {
color: var(--UI-Text-Placeholder);
display: block;

View File

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

View File

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

View File

@@ -64,10 +64,16 @@ export enum PaymentMethodEnum {
discover = "discover",
}
export enum PaymentErrorCodeEnum {
Abandoned = 5,
Cancelled = 6,
Failed = 7,
export enum BookingErrorCodeEnum {
InternalError = "InternalError",
ReservationError = "ReservationError",
AvailabilityError = "AvailabilityError",
BookingStatusNotFound = "BookingStatusNotFound",
TransactionAbandoned = "TransactionAbandoned",
TransactionCancelled = "TransactionCancelled",
TransactionFailed = "TransactionFailed",
BookingStateError = "BookingStateError",
MembershipFailedError = "MembershipFailedError",
}
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 { useIntl } from "react-intl"
import { PaymentErrorCodeEnum } from "@/constants/booking"
import { BookingErrorCodeEnum } from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { toast } from "@/components/TempDesignSystem/Toasts"
@@ -19,9 +19,9 @@ export function usePaymentFailedToast() {
const router = useRouter()
const getErrorMessage = useCallback(
(errorCode: PaymentErrorCodeEnum) => {
(errorCode: string | null) => {
switch (errorCode) {
case PaymentErrorCodeEnum.Cancelled:
case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({
id: "You have now cancelled your payment.",
})
@@ -34,8 +34,7 @@ export function usePaymentFailedToast() {
[intl]
)
const errorCodeString = searchParams.get("errorCode")
const errorCode = Number(errorCodeString) as PaymentErrorCodeEnum
const errorCode = searchParams.get("errorCode")
const errorMessage = getErrorMessage(errorCode)
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(() => {
const toastType =
errorCode === PaymentErrorCodeEnum.Cancelled ? "warning" : "error"
errorCode === BookingErrorCodeEnum.TransactionCancelled
? "warning"
: "error"
toast[toastType](errorMessage)
})

View File

@@ -575,6 +575,7 @@
"Room charge": "Værelsesafgift",
"Room details": "Room details",
"Room facilities": "Værelsesfaciliteter",
"Room sold out": "Værelse solgt ud",
"Room total": "Værelse total",
"Room {roomIndex}": "Værelse {roomIndex}",
"Rooms": "Værelser",
@@ -686,6 +687,7 @@
"Type of bed": "Sengtype",
"Type of room": "Værelsestype",
"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",
"Unpaid": "Ikke betalt",
"Until {time}, {date}": "Indtil {time} den {date}",

View File

@@ -574,6 +574,7 @@
"Room charge": "Zimmerpreis",
"Room details": "Room details",
"Room facilities": "Zimmerausstattung",
"Room sold out": "Zimmer verkauft",
"Room total": "Zimmer total",
"Room {roomIndex}": "Zimmer {roomIndex}",
"Rooms": "Räume",
@@ -684,6 +685,7 @@
"Type of bed": "Bettentyp",
"Type of room": "Zimmerart",
"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",
"Unpaid": "Nicht bezahlt",
"Until {time}, {date}": "Bis {time} am {date}",

View File

@@ -581,6 +581,7 @@
"Room charge": "Room charge",
"Room details": "Room details",
"Room facilities": "Room facilities",
"Room sold out": "Room sold out",
"Room total": "Room total",
"Room {roomIndex}": "Room {roomIndex}",
"Rooms": "Rooms",
@@ -692,6 +693,7 @@
"Type of bed": "Type of bed",
"Type of room": "Type of room",
"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",
"Unpaid": "Unpaid",
"Until {time}, {date}": "Until {time}, {date}",

View File

@@ -573,6 +573,7 @@
"Room charge": "Huonemaksu",
"Room details": "Room details",
"Room facilities": "Huoneen varustelu",
"Room sold out": "Huone slutsattu",
"Room total": "Huoneen kokonaishinta",
"Room {roomIndex}": "Huone {roomIndex}",
"Rooms": "Huoneet",
@@ -684,6 +685,7 @@
"Type of bed": "Vuodetyyppi",
"Type of room": "Huonetyyppi",
"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",
"Unpaid": "Maksettaa",
"Until {time}, {date}": "Asti {time}, {date}",

View File

@@ -572,6 +572,7 @@
"Room charge": "Rumspris",
"Room details": "Room details",
"Room facilities": "Rumfaciliteter",
"Room sold out": "Rum slutsålt",
"Room total": "Rum total",
"Room {roomIndex}": "Rum {roomIndex}",
"Rooms": "Rum",
@@ -682,6 +683,7 @@
"Type of bed": "Sängtyp",
"Type of room": "Rumstyp",
"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",
"Unpaid": "Ej betalt",
"Until {time}, {date}": "Tills {time} den {date}",

View File

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

View File

@@ -37,6 +37,7 @@ export default function EnterDetailsProvider({
rooms: rooms
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
.map((room) => ({
isAvailable: room.isAvailable,
breakfastIncluded: !!room.breakfastIncluded,
cancellationText: room.cancellationText,
rateDetails: room.rateDetails,
@@ -86,6 +87,10 @@ export default function EnterDetailsProvider({
// since store is readonly
const currentRoom = deepmerge({}, store.rooms[idx])
if (!currentRoom.room.isAvailable) {
return currentRoom
}
if (!currentRoom.room.bedType && storedRoom.room.bedType) {
const sameBed = currentRoom.room.bedTypes.find(
(bedType) => bedType.value === storedRoom.room.bedType?.roomTypeCode
@@ -134,7 +139,9 @@ export default function EnterDetailsProvider({
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 currency = (updatedRooms[0].room.roomRate.publicRate?.localPrice

View File

@@ -1,4 +1,5 @@
import { metrics } from "@opentelemetry/api"
import sjson from "secure-json-parse"
import * as api from "@/lib/api"
import { getVerifiedUser } from "@/server/routers/user/query"
@@ -11,8 +12,8 @@ import {
cancelBookingInput,
createBookingInput,
priceChangeInput,
updateBookingInput,
removePackageInput,
updateBookingInput,
} from "./input"
import { createBookingSchema } from "./output"
@@ -135,10 +136,17 @@ export const bookingMutationRouter = router({
error: {
status: apiResponse.status,
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
}

View File

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

View File

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

View File

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

View File

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