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,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>
)
}