Merged in feat(SW-1279)-mystay-multirum-cancelling (pull request #1443)

Feat(SW-1279) mystay multirum cancelling

* feat(SW-1279) Cancelation text if non-user on room 2-4

* feat(SW-1279) cancel mystay multiroom

* feat(SW-1279): Added cancellation for multiroom on mystay


Approved-by: Niclas Edenvin
This commit is contained in:
Pontus Dreij
2025-02-28 07:17:25 +00:00
parent bee6c6d83a
commit 69139c5230
24 changed files with 646 additions and 168 deletions

View File

@@ -1,32 +1,29 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import PriceContainer from "../Pricecontainer"
import styles from "../cancelStay.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface CancelStayConfirmationProps {
hotel: Hotel
booking: BookingConfirmation["booking"]
stayDetails: {
checkInDate: string
checkOutDate: string
nightsText: string
adultsText: string
childrenText: string
}
}
import type {
CancelStayConfirmationProps,
FormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
export function CancelStayConfirmation({
hotel,
booking,
stayDetails,
roomDetails = [],
}: CancelStayConfirmationProps) {
const intl = useIntl()
const { getValues } = useFormContext<FormValues>()
return (
<>
@@ -47,24 +44,51 @@ export function CancelStayConfirmation({
{intl.formatMessage({ id: "No charges were made." })}
</Caption>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
{booking.multiRoom && (
<>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "Select rooms" })}
</Body>
<div className={styles.rooms}>
{getValues("rooms").map((room, index) => {
// Find room details from store by confirmationNumber
const roomDetail = roomDetails.find(
(detail) => detail.id === room.confirmationNumber
)
return (
<div key={room.id} className={styles.roomContainer}>
<Checkbox
name={`rooms.${index}.checked`}
registerOptions={{
disabled:
roomDetail?.rateDefinition.cancellationRule !==
"CancellableBefore6PM",
}}
>
<div className={styles.roomInfo}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Room" })} {index + 1}
</Caption>
{roomDetail && (
<>
<Body color="uiTextHighContrast">
{roomDetail.roomName}
</Body>
</>
)}
</div>
</Checkbox>
</div>
)
})}
</div>
</>
)}
{getValues("rooms").some((room) => room.checked) && (
<PriceContainer booking={booking} stayDetails={stayDetails} />
)}
</>
)
}

View File

@@ -1,21 +1,12 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import PriceContainer from "../Pricecontainer"
import styles from "../cancelStay.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface FinalConfirmationProps {
booking: BookingConfirmation["booking"]
stayDetails: {
nightsText: string
adultsText: string
childrenText: string
}
}
import type { FinalConfirmationProps } from "@/types/components/hotelReservation/myStay/cancelStay"
export function FinalConfirmation({
booking,
@@ -32,24 +23,7 @@ export function FinalConfirmation({
})}
</Body>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
<PriceContainer booking={booking} stayDetails={stayDetails} />
</>
)
}

View File

@@ -0,0 +1,45 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getCheckedRoomsCounts } from "../utils"
import styles from "../cancelStay.module.css"
import type {
FormValues,
PriceContainerProps,
} from "@/types/components/hotelReservation/myStay/cancelStay"
export default function PriceContainer({
booking,
stayDetails,
}: PriceContainerProps) {
const intl = useIntl()
const { getValues } = useFormContext<FormValues>()
const checkedRoomsDetails = getCheckedRoomsCounts(booking, getValues, intl)
return (
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {checkedRoomsDetails.adultsText}
{checkedRoomsDetails.totalChildren > 0
? `, ${checkedRoomsDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
)
}

View File

@@ -13,12 +13,36 @@
justify-content: flex-end;
}
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.roomContainer {
display: flex;
padding: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
align-items: center;
gap: var(--Spacing-x1);
}
.roomInfo {
display: flex;
flex-direction: column;
}
.info {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x2);
text-align: right;
display: flex;
flex-direction: column;
}
.price {
padding-left: var(--Spacing-x2);
display: flex;
align-items: center;
}

View File

@@ -6,14 +6,22 @@ import { trpc } from "@/lib/trpc/client"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type { CancelStayProps } from ".."
import type {
CancelStayProps,
FormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
interface UseCancelStayProps extends Omit<CancelStayProps, "hotel"> {
getFormValues: () => FormValues // Function to get form values
}
export default function useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
}: Omit<CancelStayProps, "hotel">) {
getFormValues,
}: UseCancelStayProps) {
const intl = useIntl()
const lang = useLang()
const [currentStep, setCurrentStep] = useState(1)
@@ -21,39 +29,9 @@ export default function useCancelStay({
const cancelStay = trpc.booking.cancel.useMutation({
onMutate: () => setIsLoading(true),
onSuccess: (result) => {
if (!result) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
setBookingStatus()
toast.success(
intl.formatMessage(
{
id: "Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out",
},
{ currency: booking.currencyCode }
)
)
},
onError: () => {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
},
onSettled: () => {
handleCloseModal()
},
})
function handleCancelStay() {
async function handleCancelStay() {
if (!booking.confirmationNumber) {
toast.error(
intl.formatMessage({
@@ -63,10 +41,86 @@ export default function useCancelStay({
return
}
cancelStay.mutate({
confirmationNumber: booking.confirmationNumber,
language: lang,
})
setIsLoading(true)
try {
// Get form values using the provided getter function
const formValues = getFormValues()
const { rooms } = formValues
const checkedRooms = rooms.filter((room) => room.checked)
const results = []
const errors = []
// Process each checked room sequentially
for (const room of checkedRooms) {
const confirmationNumber =
room.confirmationNumber || booking.confirmationNumber
try {
const result = await cancelStay.mutateAsync({
confirmationNumber: confirmationNumber,
language: lang,
})
if (result) {
results.push(room.id)
} else {
errors.push(room.id)
}
} catch (error) {
console.error(
`Error cancelling room ${room.confirmationNumber}:`,
error
)
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
errors.push(room.id)
}
}
// Handle results
if (results.length > 0 && errors.length === 0) {
// All rooms were cancelled successfully
setBookingStatus()
toast.success(
intl.formatMessage(
{
id: "Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out",
},
{ currency: booking.currencyCode }
)
)
} else if (results.length > 0 && errors.length > 0) {
// Some rooms were cancelled, some failed
setBookingStatus()
toast.warning(
intl.formatMessage({
id: "Some rooms were cancelled successfully, but we encountered issues with others. Please contact customer service for assistance.",
})
)
} else {
// All rooms failed to cancel
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
}
handleCloseModal()
} catch (error) {
console.error("Error in handleCancelStay:", error)
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
setIsLoading(false)
}
}
function handleCloseCancelStay() {

View File

@@ -1,24 +1,29 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import { ModalContent } from "../ManageStay/ModalContent"
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
import useCancelStay from "./hooks/useCancelStay"
import { CancelStayConfirmation } from "./Confirmation"
import { FinalConfirmation } from "./FinalConfirmation"
import { formatStayDetails } from "./utils"
import { formatStayDetails, getDefaultRooms } from "./utils"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import {
type CancelStayProps,
cancelStaySchema,
type FormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
import { AlertTypeEnum } from "@/types/enums/alert"
export interface CancelStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: () => void
handleCloseModal: () => void
handleBackToManageStay: () => void
const MODAL_STEPS = {
INITIAL: 1,
CONFIRMATION: 2,
}
export default function CancelStay({
@@ -30,6 +35,17 @@ export default function CancelStay({
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
const { mainRoom } = booking
const form = useForm<FormValues>({
resolver: zodResolver(cancelStaySchema),
defaultValues: {
rooms: getDefaultRooms(booking),
},
})
const {
currentStep,
isLoading,
@@ -41,47 +57,82 @@ export default function CancelStay({
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
getFormValues: form.getValues,
})
const stayDetails = formatStayDetails({ booking, lang, intl })
const isFirstStep = currentStep === 1
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
function getModalCopy() {
if (isFirstStep) {
return {
title: intl.formatMessage({ id: "Cancel stay" }),
primaryLabel: intl.formatMessage({ id: "Cancel stay" }),
secondaryLabel: intl.formatMessage({ id: "Back" }),
}
} else {
return {
title: intl.formatMessage({ id: "Confirm cancellation" }),
primaryLabel: intl.formatMessage({ id: "Confirm cancellation" }),
secondaryLabel: intl.formatMessage({ id: "Don't cancel" }),
}
}
}
function getModalContent() {
if (mainRoom && isFirstStep)
return (
<CancelStayConfirmation
hotel={hotel}
booking={booking}
stayDetails={stayDetails}
roomDetails={roomDetails}
/>
)
if (mainRoom && !isFirstStep)
return <FinalConfirmation booking={booking} stayDetails={stayDetails} />
if (!mainRoom && isFirstStep)
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
id: "Contact the person who booked the stay",
})}
text={intl.formatMessage({
id: "As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.",
})}
/>
)
}
const { rooms } = form.watch()
const isFormValid = rooms?.some((room) => room.checked)
return (
<>
<FormProvider {...form}>
<ModalContent
title={
isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" })
}
title={getModalCopy().title}
onClose={handleCloseModal}
content={
isFirstStep ? (
<CancelStayConfirmation
hotel={hotel}
booking={booking}
stayDetails={stayDetails}
/>
) : (
<FinalConfirmation booking={booking} stayDetails={stayDetails} />
)
content={getModalContent()}
primaryAction={
mainRoom
? {
label: getModalCopy().primaryLabel,
onClick: isFirstStep ? handleForward : handleCancelStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
disabled: !isFormValid,
}
: null
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" }),
onClick: isFirstStep ? handleForward : handleCancelStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Back" })
: intl.formatMessage({ id: "Don't cancel" }),
label: getModalCopy().secondaryLabel,
onClick: isFirstStep ? handleCloseCancelStay : handleCloseModal,
intent: "text",
}}
/>
</>
</FormProvider>
)
}

View File

@@ -1,9 +1,28 @@
import { dt } from "@/lib/dt"
import type { UseFormReturn } from "react-hook-form"
import type { IntlShape } from "react-intl"
import type { FormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function getDefaultRooms(booking: BookingConfirmation["booking"]) {
const { multiRoom, confirmationNumber, linkedReservations = [] } = booking
if (!multiRoom) {
return [{ id: "1", checked: true, confirmationNumber }]
}
const mainRoom = { id: "1", checked: false, confirmationNumber }
const linkedRooms = linkedReservations.map((reservation, index) => ({
id: `${index + 2}`,
checked: false,
confirmationNumber: reservation.confirmationNumber,
}))
return [mainRoom, ...linkedRooms]
}
export function formatStayDetails({
booking,
lang,
@@ -13,6 +32,20 @@ export function formatStayDetails({
lang: string
intl: IntlShape
}) {
const { multiRoom } = booking
const totalAdults = multiRoom
? (booking.adults ?? 0) +
(booking.linkedReservations ?? []).reduce((acc, reservation) => {
return acc + (reservation.adults ?? 0)
}, 0)
: (booking.adults ?? 0)
const totalChildren = multiRoom
? booking.childrenAges?.length +
(booking.linkedReservations ?? []).reduce((acc, reservation) => {
return acc + reservation.children
}, 0)
: booking.childrenAges?.length
const checkInDate = dt(booking.checkInDate)
.locale(lang)
.format("dddd D MMM YYYY")
@@ -27,11 +60,11 @@ export function formatStayDetails({
)
const adultsText = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: booking.adults }
{ totalAdults: totalAdults }
)
const childrenText = intl.formatMessage(
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
{ totalChildren: booking.childrenAges?.length }
{ totalChildren: totalChildren }
)
return {
@@ -42,3 +75,72 @@ export function formatStayDetails({
childrenText,
}
}
function getMatchedRooms(
booking: BookingConfirmation["booking"],
checkedConfirmationNumbers: string[]
) {
let matchedRooms = []
// Main booking
if (checkedConfirmationNumbers.includes(booking.confirmationNumber ?? "")) {
matchedRooms.push({
adults: booking.adults ?? 0,
children: booking.childrenAges?.length ?? 0,
})
}
// Linked reservations
if (booking.linkedReservations) {
const matchedLinkedRooms = booking.linkedReservations
.filter((reservation) =>
checkedConfirmationNumbers.includes(reservation.confirmationNumber)
)
.map((reservation) => ({
adults: reservation.adults ?? 0,
children: reservation.children ?? 0,
}))
matchedRooms = [...matchedRooms, ...matchedLinkedRooms]
}
return matchedRooms
}
function calculateTotals(matchedRooms: { adults: number; children: number }[]) {
const totalAdults = matchedRooms.reduce((sum, room) => sum + room.adults, 0)
const totalChildren = matchedRooms.reduce(
(sum, room) => sum + room.children,
0
)
return { totalAdults, totalChildren }
}
export const getCheckedRoomsCounts = (
booking: BookingConfirmation["booking"],
getValues: UseFormReturn<FormValues>["getValues"],
intl: IntlShape
) => {
const formRooms = getValues("rooms")
const checkedFormRooms = formRooms.filter((room) => room.checked)
const checkedConfirmationNumbers = checkedFormRooms
.map((room) => room.confirmationNumber)
.filter(
(confirmationNumber): confirmationNumber is string =>
confirmationNumber !== null && confirmationNumber !== undefined
)
const matchedRooms = getMatchedRooms(booking, checkedConfirmationNumbers)
const { totalAdults, totalChildren } = calculateTotals(matchedRooms)
const adultsText = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: totalAdults }
)
const childrenText = intl.formatMessage(
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
{ totalChildren: totalChildren }
)
return { adultsText, childrenText, totalChildren }
}