Merged in feat(SW-1274)-modify-date-my-stay (pull request #1528)

Feat(SW-1274) modify date my stay

* feat(SW-1676): Modify guest details step 1

* feat(SW-1676) Integration to api to update guest details

* feat(SW-1676) Reuse of old modal

* feat(SW-1676) updated modify guest

* feat(SW-1676) cleanup

* feat(SW-1274) modify stay modal and datepicker

* feat(SW-1274) DatePicker from modify dates

* feat(SW-1274) Modify dates fixes and merge conflicts

* feat(SW-1274) handle modify for multiroom

* feat(SW-1274) update manage stay

* feat(SW-1274) fixed some comments

* feat(SW-1274) use Modal instead

* feat(SW-1274) fixed formatChildBedPreferences

* feat(SW-1274) removed any as prop

* feat(SW-1274) fix rebase conflicts

* feat(SW-1274) fix flicker on modify modal

* feat(SW-1274) CalendarButton

* feat(SW-1274) fixed gap variable

* feat(SW-1274) simplified code

* feat(SW-1274) Split up DatePicker on mode

* feat(SW-1274) Updated file structure for datepicker


Approved-by: Arvid Norlin
This commit is contained in:
Pontus Dreij
2025-03-19 13:11:03 +00:00
parent b0aea68ee5
commit fb321cdb13
54 changed files with 1986 additions and 321 deletions

View File

@@ -0,0 +1,27 @@
import { useIntl } from "react-intl"
import PriceContainer from "../../../PriceContainer"
import { useCheckedRoomsCounts } from "../utils"
import type { PriceContainerProps } from "@/types/components/hotelReservation/myStay/cancelStay"
export default function CancelStayPriceContainer({
booking,
stayDetails,
}: PriceContainerProps) {
const intl = useIntl()
const checkedRoomsDetails = useCheckedRoomsCounts(booking, intl)
return (
<PriceContainer
text={intl.formatMessage({ id: "Cancellation cost" })}
price={0}
currencyCode={booking.currencyCode}
nightsText={stayDetails.nightsText}
adultsText={checkedRoomsDetails.adultsText}
childrenText={checkedRoomsDetails.childrenText}
totalChildren={checkedRoomsDetails.totalChildren}
/>
)
}

View File

@@ -0,0 +1,98 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMyStayRoomDetailsStore } from "@/components/HotelReservation/MyStay/stores/myStayRoomDetailsStore"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import styles from "../cancelStay.module.css"
import type {
CancelStayConfirmationProps,
CancelStayFormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
export function CancelStayConfirmation({
hotel,
booking,
stayDetails,
}: CancelStayConfirmationProps) {
const intl = useIntl()
const { getValues } = useFormContext<CancelStayFormValues>()
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
id: "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.",
},
{
hotel: hotel.name,
checkInDate: stayDetails.checkInDate,
checkOutDate: stayDetails.checkOutDate,
}
)}
</Body>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "No charges were made." })}
</Caption>
</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?.isCancelable,
}}
>
<div className={styles.roomInfo}>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{
roomIndex: index + 1,
}
)}
</Caption>
{roomDetail && (
<>
<Body color="uiTextHighContrast">
{roomDetail.roomName}
</Body>
</>
)}
</div>
</Checkbox>
</div>
)
})}
</div>
</>
)}
{getValues("rooms").some((room) => room.checked) && (
<CancelStayPriceContainer booking={booking} stayDetails={stayDetails} />
)}
</>
)
}

View File

@@ -0,0 +1,29 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import styles from "../cancelStay.module.css"
import type { FinalConfirmationProps } from "@/types/components/hotelReservation/myStay/cancelStay"
export function FinalConfirmation({
booking,
stayDetails,
}: FinalConfirmationProps) {
const intl = useIntl()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage({
id: "Are you sure you want to continue with the cancellation?",
})}
</Body>
</div>
<CancelStayPriceContainer booking={booking} stayDetails={stayDetails} />
</>
)
}

View File

@@ -0,0 +1,25 @@
.modalText {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.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;
}

View File

@@ -0,0 +1,123 @@
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { useManageStayStore } from "@/components/HotelReservation/MyStay/stores/manageStayStore"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type {
CancelStayFormValues,
CancelStayProps,
} from "@/types/components/hotelReservation/myStay/cancelStay"
interface UseCancelStayProps extends Omit<CancelStayProps, "hotel"> {
getFormValues: () => CancelStayFormValues
}
export default function useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
getFormValues,
}: UseCancelStayProps) {
const intl = useIntl()
const lang = useLang()
const {
actions: { setIsLoading },
} = useManageStayStore()
const cancelStay = trpc.booking.cancel.useMutation({
onMutate: () => setIsLoading(true),
})
async function handleCancelStay() {
if (!booking.confirmationNumber) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
setIsLoading(true)
try {
const formValues = getFormValues()
const { rooms } = formValues
const checkedRooms = rooms.filter((room) => room.checked)
const results = []
const errors = []
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)
}
}
if (results.length > 0 && errors.length === 0) {
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) {
setBookingStatus()
toast.warning(
intl.formatMessage({
id: "Some rooms were cancelled successfully, but we encountered issues with others. Please contact customer service for assistance.",
})
)
} else {
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)
}
}
return {
handleCancelStay,
}
}

View File

@@ -0,0 +1,134 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useManageStayStore } from "@/components/HotelReservation/MyStay/stores/manageStayStore"
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import useCancelStay from "./hooks/useCancelStay"
import { CancelStayConfirmation } from "./Confirmation"
import { FinalConfirmation } from "./FinalConfirmation"
import { formatStayDetails, getDefaultRooms } from "./utils"
import {
type CancelStayFormValues,
cancelStaySchema,
} from "@/types/components/hotelReservation/myStay/cancelStay"
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface CancelStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: () => void
}
export default function CancelStay({
booking,
hotel,
setBookingStatus,
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const form = useForm<CancelStayFormValues>({
resolver: zodResolver(cancelStaySchema),
defaultValues: {
rooms: getDefaultRooms(booking),
},
})
const {
currentStep,
isLoading,
actions: { handleForward, handleCloseView, handleCloseModal },
} = useManageStayStore()
const { handleCancelStay } = useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
getFormValues: form.getValues,
})
const { mainRoom } = booking
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const stayDetails = formatStayDetails({ booking, lang, intl })
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}
/>
)
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}>
<ModalContentWithActions
title={getModalCopy().title}
content={getModalContent()}
onClose={handleCloseModal}
primaryAction={
mainRoom
? {
label: getModalCopy().primaryLabel,
onClick: isFirstStep ? handleForward : handleCancelStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
disabled: !isFormValid,
}
: null
}
secondaryAction={{
label: getModalCopy().secondaryLabel,
onClick: isFirstStep ? handleCloseView : handleCloseModal,
intent: "text",
}}
/>
</FormProvider>
)
}

View File

@@ -0,0 +1,149 @@
import { useFormContext } from "react-hook-form"
import { dt } from "@/lib/dt"
import type { IntlShape } from "react-intl"
import type { CancelStayFormValues } 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,
intl,
}: {
booking: BookingConfirmation["booking"]
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")
const checkOutDate = dt(booking.checkOutDate)
.locale(lang)
.format("dddd D MMM YYYY")
const diff = dt(checkOutDate).diff(checkInDate, "days")
const nightsText = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
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 {
checkInDate,
checkOutDate,
nightsText,
adultsText,
childrenText,
totalChildren,
}
}
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 useCheckedRoomsCounts = (
booking: BookingConfirmation["booking"],
intl: IntlShape
) => {
const { getValues } = useFormContext<CancelStayFormValues>()
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 }
}

View File

@@ -0,0 +1,32 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.dateComparison {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.dateGroup {
display: flex;
flex-direction: column;
}
.dateHeader {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.dates {
display: flex;
flex-direction: column;
}
.date {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,140 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import PriceContainer from "@/components/HotelReservation/MyStay/ManageStay/ActionPanel/PriceContainer"
import { useMyStayTotalPriceStore } from "@/components/HotelReservation/MyStay/stores/myStayTotalPrice"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import styles from "./confirmation.module.css"
interface ConfirmationProps {
oldPrice: number
newPrice: number
stayDetails: {
checkInDate: string
checkOutDate: string
nightsText: string
adultsText: string
childrenText: string
totalChildren: number
}
}
export default function Confirmation({
oldPrice,
newPrice,
stayDetails,
}: ConfirmationProps) {
const intl = useIntl()
const lang = useLang()
const { getValues } = useFormContext()
const { currencyCode } = useMyStayTotalPriceStore()
const formValues = getValues()
const originalCheckIn = dt(stayDetails.checkInDate)
.locale(lang)
.format("dddd, DD MMM, YYYY")
const originalCheckOut = dt(stayDetails.checkOutDate)
.locale(lang)
.format("dddd, DD MMM, YYYY")
const newCheckIn = dt(formValues.checkInDate)
.locale(lang)
.format("dddd, DD MMM, YYYY")
const newCheckOut = dt(formValues.checkOutDate)
.locale(lang)
.format("dddd, DD MMM, YYYY")
return (
<div className={styles.container}>
<div className={styles.dateComparison}>
<div className={styles.dateGroup}>
<div className={styles.dateHeader}>
<Caption
color="uiTextMediumContrast"
type="bold"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Old dates" })}
</Caption>
<Body color="uiTextMediumContrast">
{oldPrice} {currencyCode}
</Body>
</div>
<div className={styles.dates}>
<div className={styles.date}>
<Caption
color="uiTextMediumContrast"
type="bold"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Check-in" })}
</Caption>
<Body color="uiTextMediumContrast">{originalCheckIn}</Body>
</div>
<div className={styles.date}>
<Caption
color="uiTextMediumContrast"
type="bold"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Check-out" })}
</Caption>
<Body color="uiTextMediumContrast">{originalCheckOut}</Body>
</div>
</div>
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.dateGroup}>
<div className={styles.dateHeader}>
<Caption color="red" type="bold" textTransform="uppercase">
{intl.formatMessage({ id: "New dates" })}
</Caption>
<Body color="red">
{newPrice} {currencyCode}
</Body>
</div>
<div className={styles.dates}>
<div className={styles.date}>
<Caption
color="uiTextMediumContrast"
type="bold"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Check-in" })}
</Caption>
<Body color="uiTextMediumContrast">{newCheckIn}</Body>
</div>
<div className={styles.date}>
<Caption
color="uiTextMediumContrast"
type="bold"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Check-out" })}
</Caption>
<Body color="uiTextMediumContrast">{newCheckOut}</Body>
</div>
</div>
</div>
</div>
<PriceContainer
text={intl.formatMessage({ id: "To be paid" })}
price={newPrice}
currencyCode={currencyCode}
nightsText={stayDetails.nightsText}
adultsText={stayDetails.adultsText}
childrenText={stayDetails.childrenText}
totalChildren={stayDetails.totalChildren}
/>
</div>
)
}

View File

@@ -0,0 +1,14 @@
.button {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0; /* allow shrinkage */
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { CalendarIcon } from "@/components/Icons"
import styles from "./calendarButton.module.css"
interface CalendarButtonProps {
text: string
onClick: () => void
}
export default function CalendarButton({ text, onClick }: CalendarButtonProps) {
return (
<ButtonRAC onPress={onClick} className={styles.button}>
<Typography variant="Body/Paragraph/mdRegular">
<span>{text}</span>
</Typography>
<CalendarIcon />
</ButtonRAC>
)
}

View File

@@ -0,0 +1,237 @@
import { da, de, fi, nb, sv } from "date-fns/locale"
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import DatePickerSingleDesktop from "@/components/DatePicker/Single/Desktop"
import DatePickerSingleMobile from "@/components/DatePicker/Single/Mobile"
import Modal from "@/components/Modal"
import Alert from "@/components/TempDesignSystem/Alert"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import CalendarButton from "./CalendarButton"
import styles from "./newDates.module.css"
import type { DateRange } from "react-day-picker"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { RoomDetails } from "@/components/HotelReservation/MyStay/stores/myStayRoomDetailsStore"
const locales = {
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}
interface NewDatesProps {
mainRoom: RoomDetails
noAvailability: boolean
error: boolean
}
export default function NewDates({
mainRoom,
noAvailability,
error,
}: NewDatesProps) {
const [showCheckInDatePicker, setShowCheckInDatePicker] = useState(false)
const [showCheckOutDatePicker, setShowCheckOutDatePicker] = useState(false)
const [selectedDates, setSelectedDates] = useState<DateRange>(() => ({
from: dt(mainRoom.checkInDate).startOf("day").toDate(),
to: dt(mainRoom.checkOutDate).startOf("day").toDate(),
}))
const intl = useIntl()
const lang = useLang()
const { setValue } = useFormContext()
// Initialize form values on mount
useEffect(() => {
setValue("checkInDate", dt(mainRoom.checkInDate).format("YYYY-MM-DD"))
setValue("checkOutDate", dt(mainRoom.checkOutDate).format("YYYY-MM-DD"))
}, [mainRoom.checkInDate, mainRoom.checkOutDate, setValue])
// Calculate default number of days between check-in and check-out
const defaultDaysBetween = dt(mainRoom.checkOutDate)
.startOf("day")
.diff(dt(mainRoom.checkInDate).startOf("day"), "days")
function showCheckInPicker() {
// Update selected dates before showing picker
setSelectedDates((prev) => ({
from: prev.from ?? dt(mainRoom.checkInDate).startOf("day").toDate(),
to: prev.to ?? dt(mainRoom.checkOutDate).startOf("day").toDate(),
}))
setShowCheckInDatePicker(true)
setShowCheckOutDatePicker(false)
}
function showCheckOutPicker() {
// Update selected dates before showing picker
setSelectedDates((prev) => ({
from: prev.from ?? dt(mainRoom.checkInDate).startOf("day").toDate(),
to: prev.to ?? dt(mainRoom.checkOutDate).startOf("day").toDate(),
}))
setShowCheckOutDatePicker(true)
setShowCheckInDatePicker(false)
}
function handleCheckInDateSelect(date: Date) {
const newCheckIn = dt(date).startOf("day")
const currentCheckOut = dt(selectedDates.to).startOf("day")
// Calculate new check-out date based on defaultDaysBetween, only if new check-in is after current check-out
const newCheckOut = newCheckIn.isSameOrAfter(currentCheckOut)
? newCheckIn.add(defaultDaysBetween, "days")
: currentCheckOut
// Update selected dates state first
const newDates = {
from: newCheckIn.toDate(),
to: newCheckOut.toDate(),
}
setSelectedDates(newDates)
// Then update form values
setValue("checkInDate", newCheckIn.format("YYYY-MM-DD"))
setValue("checkOutDate", newCheckOut.format("YYYY-MM-DD"))
}
function handleCheckOutDateSelect(date: Date) {
const newCheckOut = dt(date).startOf("day")
const currentCheckIn = dt(selectedDates.from).startOf("day")
// Only adjust check-in if new check-out is before current check-in
const newCheckIn = newCheckOut.isBefore(currentCheckIn)
? newCheckOut.subtract(defaultDaysBetween, "days")
: currentCheckIn
// Update selected dates state
const newDates = {
from: newCheckIn.toDate(),
to: newCheckOut.toDate(),
}
setSelectedDates(newDates)
// Then update form values
setValue("checkInDate", newCheckIn.format("YYYY-MM-DD"))
setValue("checkOutDate", newCheckOut.format("YYYY-MM-DD"))
}
return (
<>
{noAvailability && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "No single rooms are available on these dates",
})}
/>
)}
{error && (
<Alert
type={AlertTypeEnum.Alarm}
heading={intl.formatMessage({ id: "Error" })}
text={intl.formatMessage({
id: "Something went wrong!",
})}
/>
)}
<div className={styles.container}>
<div className={styles.checkInDate}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Check-in" })}
</Caption>
<CalendarButton
text={dt(selectedDates.from ?? new Date())
.locale(lang)
.format("dddd, DD MMM, YYYY")}
onClick={showCheckInPicker}
/>
</div>
<div className={styles.checkOutDate}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Check-out" })}
</Caption>
<CalendarButton
text={dt(selectedDates.to ?? new Date())
.locale(lang)
.format("dddd, DD MMM, YYYY")}
onClick={showCheckOutPicker}
/>
</div>
</div>
{showCheckInDatePicker &&
createPortal(
<Modal
isOpen={showCheckInDatePicker}
onToggle={() => setShowCheckInDatePicker(!showCheckInDatePicker)}
>
<DatePickerSingleDesktop
close={() => setShowCheckInDatePicker(false)}
handleOnSelect={handleCheckInDateSelect}
locales={locales}
selectedDate={
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
}
startMonth={
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
}
/>
<DatePickerSingleMobile
close={() => setShowCheckInDatePicker(false)}
handleOnSelect={handleCheckInDateSelect}
locales={locales}
selectedDate={
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
}
hideHeader
/>
</Modal>,
document.body
)}
{showCheckOutDatePicker &&
createPortal(
<Modal
isOpen={showCheckOutDatePicker}
onToggle={() => setShowCheckOutDatePicker(!showCheckOutDatePicker)}
>
<DatePickerSingleDesktop
close={() => setShowCheckOutDatePicker(false)}
handleOnSelect={handleCheckOutDateSelect}
locales={locales}
selectedDate={
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
}
startMonth={
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
}
/>
<DatePickerSingleMobile
close={() => setShowCheckOutDatePicker(false)}
handleOnSelect={handleCheckOutDateSelect}
locales={locales}
selectedDate={
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
}
hideHeader
/>
</Modal>,
document.body
)}
</>
)
}

View File

@@ -0,0 +1,15 @@
.container {
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.checkInDate,
.checkOutDate {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,169 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { useManageStayStore } from "@/components/HotelReservation/MyStay/stores/manageStayStore"
import { useMyStayRoomDetailsStore } from "@/components/HotelReservation/MyStay/stores/myStayRoomDetailsStore"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type { UseFormGetValues } from "react-hook-form"
import type { ModifyDateSchema } from "@/types/components/hotelReservation/myStay/modifyDate"
interface UseModifyStayOptions {
booking: {
confirmationNumber: string
roomPrice?: number
currencyCode?: string
}
isLoggedIn?: boolean
getFormValues: UseFormGetValues<ModifyDateSchema>
handleCloseModal: () => void
}
export default function useModifyStay({
booking,
isLoggedIn,
getFormValues,
handleCloseModal,
}: UseModifyStayOptions) {
const intl = useIntl()
const lang = useLang()
const {
actions: { setIsLoading },
} = useManageStayStore()
const {
rooms,
actions: { updateRoomDetails },
} = useMyStayRoomDetailsStore()
const utils = trpc.useUtils()
const updateBooking = trpc.booking.update.useMutation({
onMutate: () => setIsLoading(true),
onSuccess: (updatedBooking) => {
if (!updatedBooking) return
// Update room details with server response data
for (const room of rooms) {
const originalCheckIn = dt(room.checkInDate)
const originalCheckOut = dt(room.checkOutDate)
updateRoomDetails({
...room,
checkInDate: dt(updatedBooking.checkInDate)
.hour(originalCheckIn.hour())
.minute(originalCheckIn.minute())
.second(originalCheckIn.second())
.toDate(),
checkOutDate: dt(updatedBooking.checkOutDate)
.hour(originalCheckOut.hour())
.minute(originalCheckOut.minute())
.second(originalCheckOut.second())
.toDate(),
})
}
setIsLoading(false)
toast.success(intl.formatMessage({ id: "Your stay was updated" }))
handleCloseModal()
},
onError: () => {
setIsLoading(false)
toast.error(intl.formatMessage({ id: "Failed to update your stay" }))
},
})
async function checkAvailability() {
const formValues = getFormValues()
if (!formValues.checkInDate || !formValues.checkOutDate) {
toast.error(intl.formatMessage({ id: "Please select dates" }))
return { success: false }
}
setIsLoading(true)
try {
const availabilityResults = []
let totalNewPrice = 0
for (const room of rooms) {
try {
const data = await utils.client.hotel.availability.room.query({
hotelId: room.hotelId,
roomStayStartDate: formValues.checkInDate,
roomStayEndDate: formValues.checkOutDate,
adults: room.adults,
children: room.children,
bookingCode: room.bookingCode,
rateCode: room.rateCode,
roomTypeCode: room.roomTypeCode,
lang,
})
if (!data?.selectedRoom || data.selectedRoom.roomsLeft <= 0) {
return { success: false, noAvailability: true }
}
const roomPrice = isLoggedIn
? data.memberRate?.requestedPrice?.pricePerStay
: data.publicRate?.requestedPrice?.pricePerStay
totalNewPrice += roomPrice || 0
availabilityResults.push(data)
} catch (error) {
console.error("Error checking room availability:", error)
return { success: false, error: true }
}
}
return {
success: true,
newRoomPrice: totalNewPrice,
results: availabilityResults,
}
} catch (error) {
console.error("Error checking availability:", error)
return { success: false, error: true }
} finally {
setIsLoading(false)
}
}
async function handleModifyStay() {
if (!booking.confirmationNumber) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
const formValues = getFormValues()
setIsLoading(true)
try {
await updateBooking.mutateAsync({
confirmationNumber: booking.confirmationNumber,
checkInDate: formValues.checkInDate,
checkOutDate: formValues.checkOutDate,
})
} catch (error) {
console.error("Error modifying stay:", error)
toast.error(
intl.formatMessage({
id: "Failed to update your stay. Please try again later.",
})
)
setIsLoading(false)
}
}
return {
checkAvailability,
handleModifyStay,
}
}

View File

@@ -0,0 +1,180 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useManageStayStore } from "@/components/HotelReservation/MyStay/stores/manageStayStore"
import { useMyStayRoomDetailsStore } from "@/components/HotelReservation/MyStay/stores/myStayRoomDetailsStore"
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import { formatStayDetails } from "../CancelStay/utils"
import useModifyStay from "./hooks/useModifyStay"
import Confirmation from "./Confirmation"
import NewDates from "./NewDates"
import {
type ModifyDateSchema,
modifyDateSchema,
type ModifyStayProps,
} from "@/types/components/hotelReservation/myStay/modifyDate"
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function ModifyStay({ booking, user }: ModifyStayProps) {
const intl = useIntl()
const lang = useLang()
const [error, setError] = useState(false)
const [noAvailability, setNoAvailability] = useState(false)
const [newRoomPrice, setNewRoomPrice] = useState(0)
const form = useForm<ModifyDateSchema>({
resolver: zodResolver(modifyDateSchema),
defaultValues: {
checkInDate: "",
checkOutDate: "",
},
})
const {
currentStep,
isLoading,
actions: { handleCloseView, handleCloseModal, setCurrentStep },
} = useManageStayStore()
const { rooms } = useMyStayRoomDetailsStore()
const { mainRoom: isMainRoom } = booking
const stayDetails = formatStayDetails({ booking, lang, intl })
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const mainRoom = rooms.find((room) => room.mainRoom)
const isMultiRoom = rooms.length > 1
const { checkAvailability, handleModifyStay } = useModifyStay({
booking,
isLoggedIn: !!user,
getFormValues: form.getValues,
handleCloseModal,
})
async function onCheckAvailability() {
setError(false)
setNoAvailability(false)
const result = await checkAvailability()
if (result.success) {
setNewRoomPrice(result.newRoomPrice ?? 0)
setCurrentStep(MODAL_STEPS.CONFIRMATION)
} else {
if (result.noAvailability) {
setNoAvailability(true)
}
if (result.error) {
setError(true)
}
}
}
useEffect(() => {
if (mainRoom) {
form.setValue(
"checkInDate",
dt(mainRoom.checkInDate).format("YYYY-MM-DD")
)
form.setValue(
"checkOutDate",
dt(mainRoom.checkOutDate).format("YYYY-MM-DD")
)
}
}, [mainRoom, form])
function getModalContent() {
if (mainRoom && isFirstStep && isMultiRoom) {
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
id: "Contact customer service",
})}
text={intl.formatMessage({
id: "As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.",
})}
/>
)
}
if (mainRoom && isFirstStep)
return (
<NewDates
mainRoom={mainRoom}
noAvailability={noAvailability}
error={error}
/>
)
if (mainRoom && !isFirstStep)
return (
<Confirmation
oldPrice={booking.roomPrice}
newPrice={newRoomPrice}
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, any dates changes are applicable to all rooms. Please ask the person who booked the stay to contact customer service.",
})}
/>
)
}
return (
<FormProvider {...form}>
<ModalContentWithActions
title={
isFirstStep
? intl.formatMessage({ id: "New dates for the stay" })
: intl.formatMessage({ id: "Confirm date change" })
}
content={getModalContent()}
onClose={handleCloseModal}
primaryAction={
isMainRoom && !isMultiRoom
? {
label: isFirstStep
? intl.formatMessage({ id: "Check availability" })
: intl.formatMessage({ id: "Confirm" }),
onClick: isFirstStep
? () => void onCheckAvailability()
: () => void handleModifyStay(),
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
disabled: isLoading,
}
: null
}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Back" })
: intl.formatMessage({ id: "Cancel" }),
onClick: isFirstStep ? handleCloseView : handleCloseModal,
intent: "text",
}}
/>
</FormProvider>
)
}