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

@@ -106,7 +106,7 @@
text-decoration: none;
}
@media (min-width: 1366px) {
@media (min-width: 1367px) {
.desktop {
display: block;
}

View File

@@ -67,7 +67,7 @@
text-decoration: none;
}
@media (min-width: 1366px) {
@media (min-width: 1367px) {
.mobile {
display: none;
}

View File

@@ -17,14 +17,14 @@ import useLang from "@/hooks/useLang"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
import type { DatePickerRangeProps } from "@/types/components/datepicker"
export default function DatePickerDesktop({
export default function DatePickerRangeDesktop({
close,
handleOnSelect,
locales,
selectedDate,
}: DatePickerProps) {
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()
const [month, setMonth] = useState(new Date())
@@ -58,6 +58,9 @@ export default function DatePickerDesktop({
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
}}
disabled={[
{ from: startOfMonth, to: yesterday },
@@ -120,29 +123,6 @@ export default function DatePickerDesktop({
</div>
)
},
Nav(props) {
if (Array.isArray(props.children)) {
const prevButton = props.children?.[0]
const nextButton = props.children?.[1]
return (
<>
{prevButton ? (
<nav
className={`${props.className} ${styles.previousButton}`}
>
{prevButton}
</nav>
) : null}
{nextButton ? (
<nav className={`${props.className} ${styles.nextButton}`}>
{nextButton}
</nav>
) : null}
</>
)
}
return <></>
},
}}
/>
)

View File

@@ -14,14 +14,14 @@ import useLang from "@/hooks/useLang"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
import type { DatePickerRangeProps } from "@/types/components/datepicker"
export default function DatePickerMobile({
export default function DatePickerRangeMobile({
close,
handleOnSelect,
locales,
selectedDate,
}: DatePickerProps) {
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()

View File

@@ -111,11 +111,18 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
margin-top: var(--Spacing-x2);
}
.nextButton {
transform: rotate(180deg);
right: 0;
.nav {
width: 100%;
display: flex;
justify-content: space-between;
}
.previousButton {
left: 0;
.nav .button_next {
transform: rotate(180deg);
margin-left: auto;
}
.nav .button_previous:disabled,
.nav .button_next:disabled {
display: none;
}

View File

@@ -10,6 +10,11 @@
position: relative;
}
.container.noHeader {
grid-template-areas: "content";
grid-template-rows: auto;
}
.root {
display: grid;
grid-area: content;
@@ -130,10 +135,6 @@ td.day[data-today="true"] {
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}

View File

@@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { ChevronLeftIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerSingleProps } from "@/types/components/datepicker"
export default function DatePickerSingleDesktop({
close,
handleOnSelect,
locales,
selectedDate,
startMonth,
}: DatePickerSingleProps) {
const lang = useLang()
const intl = useIntl()
const [month, setMonth] = useState(() => startMonth ?? new Date())
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt().add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
function handleMonthChange(selected: Date) {
setMonth(selected)
}
return (
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
footer: styles.footer,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: `${classNames.months} ${styles.months}`,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
}}
disabled={[
{ from: startOfMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
footer
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
lang={lang}
locale={locale}
mode="single"
month={month}
numberOfMonths={2}
onDayClick={handleOnSelect}
onMonthChange={handleMonthChange}
required={false}
selected={selectedDate}
startMonth={currentDate}
endMonth={endDate}
weekStartsOn={1}
components={{
Chevron(props) {
return (
<ChevronLeftIcon
className={props.className}
height={20}
width={20}
/>
)
},
Footer(props) {
return (
<>
<Divider className={styles.divider} color="primaryLightSubtle" />
<footer className={props.className}>
<Button
intent="tertiary"
onPress={close}
size="small"
theme="base"
>
<Caption color="white" type="bold" asChild>
<span>{intl.formatMessage({ id: "Select date" })}</span>
</Caption>
</Button>
</footer>
</>
)
},
MonthCaption(props) {
return (
<div className={props.className}>
<Subtitle asChild type="two">
{props.children}
</Subtitle>
</div>
)
},
}}
/>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
import type { DatePickerSingleProps } from "@/types/components/datepicker"
export default function DatePickerSingleMobile({
close,
handleOnSelect,
locales,
selectedDate,
hideHeader,
}: DatePickerSingleProps) {
const lang = useLang()
const intl = useIntl()
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfCurrentMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt().add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
return (
<div className={`${styles.container} ${hideHeader ? styles.noHeader : ""}`}>
<header className={styles.header}>
<button className={styles.close} onClick={close} type="button">
<CloseLargeIcon />
</button>
</header>
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
month: styles.month,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: styles.months,
root: `${classNames.root} ${styles.root}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={[
{ from: startOfCurrentMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
endMonth={endDate}
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
hideNavigation
lang={lang}
locale={locale}
mode="single"
/** Showing full year or what's left of it */
numberOfMonths={13}
onDayClick={handleOnSelect}
required
selected={selectedDate}
startMonth={currentDate}
weekStartsOn={1}
components={{
MonthCaption(props) {
return (
<div className={props.className}>
<Subtitle asChild type="two">
{props.children}
</Subtitle>
</div>
)
},
}}
/>
<footer className={styles.footer}>
<Button
className={styles.button}
intent="tertiary"
onPress={close}
size="large"
theme="base"
>
<Body color="white" textTransform="bold" asChild>
<span>{intl.formatMessage({ id: "Select dates" })}</span>
</Body>
</Button>
</footer>
</div>
)
}

View File

@@ -0,0 +1,127 @@
@media screen and (max-width: 1366px) {
.container {
display: none;
}
}
div.months {
flex-wrap: nowrap;
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Base-Background-Primary-Normal);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: var(--Spacing-x2);
}
.divider {
margin-top: var(--Spacing-x2);
}
.nav {
width: 100%;
display: flex;
justify-content: space-between;
}
.nav .button_next {
transform: rotate(180deg);
margin-left: auto;
}
.nav .button_previous:disabled,
.nav .button_next:disabled {
display: none;
}

View File

@@ -0,0 +1,176 @@
.container {
--header-height: 72px;
--sticky-button-height: 120px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.container.noHeader {
grid-template-areas: "content";
grid-template-rows: auto;
}
.root {
display: grid;
grid-area: content;
}
.header {
align-self: flex-end;
background-color: var(--Main-Grey-White);
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;
}
.select {
justify-self: center;
min-width: 100px;
transform: translateX(24px);
}
.close {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
}
div.months {
display: grid;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.month {
display: grid;
justify-items: center;
scroll-snap-align: start;
}
.month:last-of-type {
padding-bottom: var(--sticky-button-height);
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
.footer {
align-self: flex-start;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
display: flex;
grid-area: content;
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
top: calc(100vh - var(--sticky-button-height));
width: 100%;
z-index: 10;
}
.footer .button {
width: 100%;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Base-Background-Primary-Normal);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Base-Background-Primary-Normal);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
}
@media screen and (min-width: 1367px) {
.container {
display: none;
}
}

View File

@@ -11,8 +11,8 @@ import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import DatePickerDesktop from "./Screen/Desktop"
import DatePickerMobile from "./Screen/Mobile"
import DatePickerRangeDesktop from "./Range/Desktop"
import DatePickerRangeMobile from "./Range/Mobile"
import styles from "./date-picker.module.css"
@@ -148,7 +148,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePickerDesktop
<DatePickerRangeDesktop
close={close}
handleOnSelect={handleSelectDate}
locales={locales}
@@ -158,7 +158,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
to: selectedDate.toDate,
}}
/>
<DatePickerMobile
<DatePickerRangeMobile
close={close}
handleOnSelect={handleSelectDate}
locales={locales}

View File

@@ -151,7 +151,7 @@
}
}
@media screen and (min-width: 1366px) {
@media screen and (min-width: 1367px) {
.input {
gap: var(--Spacing-x2);
}

View File

@@ -22,6 +22,7 @@ import { formatPrice } from "@/utils/numberFormatting"
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import { formatChildBedPreferences } from "../utils"
import SummaryCard from "./SummaryCard"
import styles from "./bookingSummary.module.css"
@@ -42,25 +43,45 @@ export default function BookingSummary({
}: BookingSummaryProps) {
const intl = useIntl()
const lang = useLang()
const { totalPrice, currencyCode, addRoomPrice } = useMyStayTotalPriceStore()
const { addRoomDetails } = useMyStayRoomDetailsStore()
const {
totalPrice,
currencyCode,
actions: { setRoomPrice },
} = useMyStayTotalPriceStore()
const {
actions: { setRoomDetails },
} = useMyStayRoomDetailsStore()
const childrenAsString = formatChildBedPreferences({
childrenAges: booking.childrenAges,
childBedPreferences: booking.childBedPreferences,
})
useEffect(() => {
// Add price information
addRoomPrice({
id: booking.confirmationNumber ?? "",
setRoomPrice({
id: booking.confirmationNumber,
totalPrice: booking.totalPrice,
currencyCode: booking.currencyCode,
isMainBooking: true,
})
// Add room details
addRoomDetails({
id: booking.confirmationNumber ?? "",
setRoomDetails({
id: booking.confirmationNumber,
hotelId: booking.hotelId,
checkInDate: booking.checkInDate,
checkOutDate: booking.checkOutDate,
adults: booking.adults,
children: childrenAsString,
roomName: room?.name ?? booking.roomTypeCode ?? "",
roomTypeCode: booking.roomTypeCode ?? "",
rateCode: booking.rateDefinition.rateCode ?? "",
bookingCode: booking.bookingCode ?? "",
isCancelable: booking.isCancelable,
mainRoom: booking.mainRoom,
})
}, [booking, room, addRoomPrice, addRoomDetails])
}, [booking, room, childrenAsString, setRoomPrice, setRoomDetails])
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const isPaid =

View File

@@ -1,45 +0,0 @@
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

@@ -10,6 +10,7 @@ import useLang from "@/hooks/useLang"
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import { formatChildBedPreferences } from "../utils"
import styles from "./linkedReservation.module.css"
@@ -27,29 +28,47 @@ export default function LinkedReservation({
const intl = useIntl()
const lang = useLang()
const { addRoomPrice } = useMyStayTotalPriceStore()
const { addRoomDetails } = useMyStayRoomDetailsStore()
const {
actions: { setRoomPrice },
} = useMyStayTotalPriceStore()
const {
actions: { setRoomDetails },
} = useMyStayRoomDetailsStore()
const bookingConfirmation = use(bookingPromise)
const { booking, room } = bookingConfirmation ?? {}
useEffect(() => {
if (booking) {
addRoomPrice({
id: booking.confirmationNumber ?? "",
const childrenAsString = formatChildBedPreferences({
childrenAges: booking.childrenAges ?? [],
childBedPreferences: booking.childBedPreferences ?? [],
})
setRoomPrice({
id: booking.confirmationNumber,
totalPrice: booking.totalPrice,
currencyCode: booking.currencyCode,
isMainBooking: false,
})
// Add room details to the store
addRoomDetails({
id: booking.confirmationNumber ?? "",
// Add room details for linked reservation to the store
setRoomDetails({
id: booking.confirmationNumber,
hotelId: booking.hotelId,
checkInDate: booking.checkInDate,
checkOutDate: booking.checkOutDate,
adults: booking.adults,
children: childrenAsString,
roomName: room?.name ?? booking.roomTypeCode ?? "",
roomTypeCode: booking.roomTypeCode ?? "",
rateCode: booking.rateDefinition.rateCode ?? "",
bookingCode: booking.bookingCode ?? "",
isCancelable: booking.isCancelable,
mainRoom: booking.mainRoom,
})
}
}, [booking, room, addRoomPrice, addRoomDetails])
}, [booking, room, setRoomPrice, setRoomDetails])
if (!booking) return null

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

@@ -3,18 +3,18 @@
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 { useMyStayRoomDetailsStore } from "../../stores/myStayRoomDetailsStore"
import PriceContainer from "../Pricecontainer"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import styles from "../cancelStay.module.css"
import type {
CancelStayConfirmationProps,
FormValues,
CancelStayFormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
export function CancelStayConfirmation({
@@ -23,7 +23,7 @@ export function CancelStayConfirmation({
stayDetails,
}: CancelStayConfirmationProps) {
const intl = useIntl()
const { getValues } = useFormContext<FormValues>()
const { getValues } = useFormContext<CancelStayFormValues>()
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
return (
@@ -91,7 +91,7 @@ export function CancelStayConfirmation({
</>
)}
{getValues("rooms").some((room) => room.checked) && (
<PriceContainer booking={booking} stayDetails={stayDetails} />
<CancelStayPriceContainer booking={booking} stayDetails={stayDetails} />
)}
</>
)

View File

@@ -2,7 +2,7 @@ import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import PriceContainer from "../Pricecontainer"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import styles from "../cancelStay.module.css"
@@ -23,7 +23,7 @@ export function FinalConfirmation({
})}
</Body>
</div>
<PriceContainer booking={booking} stayDetails={stayDetails} />
<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

@@ -1,31 +1,31 @@
import { useState } from "react"
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,
FormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
interface UseCancelStayProps extends Omit<CancelStayProps, "hotel"> {
getFormValues: () => FormValues // Function to get form values
getFormValues: () => CancelStayFormValues
}
export default function useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
getFormValues,
}: UseCancelStayProps) {
const intl = useIntl()
const lang = useLang()
const [currentStep, setCurrentStep] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const {
actions: { setIsLoading },
} = useManageStayStore()
const cancelStay = trpc.booking.cancel.useMutation({
onMutate: () => setIsLoading(true),
@@ -44,7 +44,6 @@ export default function useCancelStay({
setIsLoading(true)
try {
// Get form values using the provided getter function
const formValues = getFormValues()
const { rooms } = formValues
const checkedRooms = rooms.filter((room) => room.checked)
@@ -52,7 +51,6 @@ export default function useCancelStay({
const results = []
const errors = []
// Process each checked room sequentially
for (const room of checkedRooms) {
const confirmationNumber =
room.confirmationNumber || booking.confirmationNumber
@@ -82,9 +80,7 @@ export default function useCancelStay({
}
}
// Handle results
if (results.length > 0 && errors.length === 0) {
// All rooms were cancelled successfully
setBookingStatus()
toast.success(
intl.formatMessage(
@@ -95,7 +91,6 @@ export default function useCancelStay({
)
)
} else if (results.length > 0 && errors.length > 0) {
// Some rooms were cancelled, some failed
setBookingStatus()
toast.warning(
intl.formatMessage({
@@ -103,7 +98,6 @@ export default function useCancelStay({
})
)
} else {
// All rooms failed to cancel
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
@@ -123,18 +117,7 @@ export default function useCancelStay({
}
}
function handleCloseCancelStay() {
setCurrentStep(1)
setIsLoading(false)
handleBackToManageStay()
}
return {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleBack: () => setCurrentStep(1),
handleForward: () => setCurrentStep(2),
}
}

View File

@@ -4,6 +4,7 @@ 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"
@@ -14,26 +15,28 @@ import { FinalConfirmation } from "./FinalConfirmation"
import { formatStayDetails, getDefaultRooms } from "./utils"
import {
type CancelStayProps,
type CancelStayFormValues,
cancelStaySchema,
type FormValues,
} 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,
handleCloseModal,
handleBackToManageStay,
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const { mainRoom } = booking
const form = useForm<FormValues>({
const form = useForm<CancelStayFormValues>({
resolver: zodResolver(cancelStaySchema),
defaultValues: {
rooms: getDefaultRooms(booking),
@@ -43,19 +46,19 @@ export default function CancelStay({
const {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleForward,
} = useCancelStay({
actions: { handleForward, handleCloseView, handleCloseModal },
} = useManageStayStore()
const { handleCancelStay } = useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
getFormValues: form.getValues,
})
const stayDetails = formatStayDetails({ booking, lang, intl })
const { mainRoom } = booking
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const stayDetails = formatStayDetails({ booking, lang, intl })
function getModalCopy() {
if (isFirstStep) {
@@ -122,7 +125,7 @@ export default function CancelStay({
}
secondaryAction={{
label: getModalCopy().secondaryLabel,
onClick: isFirstStep ? handleCloseCancelStay : handleCloseModal,
onClick: isFirstStep ? handleCloseView : handleCloseModal,
intent: "text",
}}
/>

View File

@@ -1,9 +1,10 @@
import { useFormContext } from "react-hook-form"
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 { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function getDefaultRooms(booking: BookingConfirmation["booking"]) {
@@ -73,6 +74,7 @@ export function formatStayDetails({
nightsText,
adultsText,
childrenText,
totalChildren,
}
}
@@ -83,7 +85,7 @@ function getMatchedRooms(
let matchedRooms = []
// Main booking
if (checkedConfirmationNumbers.includes(booking.confirmationNumber ?? "")) {
if (checkedConfirmationNumbers.includes(booking.confirmationNumber)) {
matchedRooms.push({
adults: booking.adults ?? 0,
children: booking.childrenAges?.length ?? 0,
@@ -116,12 +118,13 @@ function calculateTotals(matchedRooms: { adults: number; children: number }[]) {
return { totalAdults, totalChildren }
}
export const getCheckedRoomsCounts = (
export const useCheckedRoomsCounts = (
booking: BookingConfirmation["booking"],
getValues: UseFormReturn<FormValues>["getValues"],
intl: IntlShape
) => {
const { getValues } = useFormContext<CancelStayFormValues>()
const formRooms = getValues("rooms")
const checkedFormRooms = formRooms.filter((room) => room.checked)
const checkedConfirmationNumbers = checkedFormRooms
.map((room) => room.confirmationNumber)

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

View File

@@ -0,0 +1,43 @@
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./priceContainer.module.css"
interface PriceContainerProps {
text: string
price: number
currencyCode: string
nightsText: string
adultsText: string
childrenText: string
totalChildren: number
}
export default function PriceContainer({
text,
price,
currencyCode,
nightsText,
adultsText,
childrenText,
totalChildren,
}: PriceContainerProps) {
return (
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{text}
</Caption>
<Caption color="uiTextHighContrast">
{nightsText}, {adultsText}
{totalChildren > 0 ? `, ${childrenText}` : ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
{price} {currencyCode}
</Subtitle>
</div>
</div>
)
}

View File

@@ -1,9 +1,3 @@
.modalText {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.priceContainer {
display: flex;
padding: var(--Spacing-x2);
@@ -13,26 +7,6 @@
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);

View File

@@ -1,16 +1,30 @@
.actionPanel {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x3);
width: 100%;
}
@media (min-width: 1367px) {
.actionPanel {
flex-direction: row;
}
}
.menu {
width: 432px;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
@media (min-width: 1367px) {
.menu {
width: 432px;
}
}
.actionPanel .menu .button {
width: 100%;
color: var(--Scandic-Brand-Burgundy);
@@ -19,7 +33,7 @@
}
.info {
width: 256px;
width: 100%;
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x3);
text-align: right;
@@ -29,6 +43,12 @@
align-items: flex-end;
}
@media (min-width: 1367px) {
.info {
width: 256px;
}
}
.tag {
text-transform: uppercase;
font-size: 12px;

View File

@@ -1,5 +1,8 @@
"use client"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { customerService } from "@/constants/currentWebHrefs"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
@@ -18,6 +21,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { useManageStayStore } from "../../stores/manageStayStore"
import AddToCalendarButton from "./Actions/AddToCalendarButton"
import styles from "./actionPanel.module.css"
@@ -30,7 +34,7 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat
interface ActionPanelProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelStayButton: boolean
bookingStatus: string | null
showGuaranteeButton: boolean
onCancelClick: () => void
onGuaranteeClick: () => void
@@ -39,13 +43,18 @@ interface ActionPanelProps {
export default function ActionPanel({
booking,
hotel,
showCancelStayButton,
bookingStatus,
showGuaranteeButton,
onCancelClick,
onGuaranteeClick,
}: ActionPanelProps) {
const intl = useIntl()
const lang = useLang()
const {
actions: { setActiveView },
} = useManageStayStore()
const showCancelStayButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
const event: EventAttributes = {
busyStatus: "FREE",
@@ -71,7 +80,7 @@ export default function ActionPanel({
<div className={styles.menu}>
<Button
variant="icon"
onClick={() => {}}
onClick={() => setActiveView("modifyStay")}
intent="text"
className={styles.button}
>
@@ -107,7 +116,7 @@ export default function ActionPanel({
{showCancelStayButton && (
<Button
variant="icon"
onClick={onCancelClick}
onClick={() => setActiveView("cancelStay")}
intent="text"
className={styles.button}
>

View File

@@ -1,6 +1,5 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
@@ -9,21 +8,22 @@ import { ChevronDownIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import CancelStay from "../CancelStay"
import GuaranteeLateArrival from "../GuaranteeLateArrival"
import { useManageStayStore } from "../stores/manageStayStore"
import CancelStay from "./ActionPanel/Actions/CancelStay"
import ModifyStay from "./ActionPanel/Actions/ModifyStay"
import ActionPanel from "./ActionPanel"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"
type ActiveView = "actionPanel" | "cancelStay" | "guaranteeLateArrival"
import { type CreditCard, type User } from "@/types/user"
interface ManageStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
user: User | null
savedCreditCards: CreditCard[] | null
refId: string
}
@@ -33,29 +33,20 @@ export default function ManageStay({
hotel,
setBookingStatus,
bookingStatus,
user,
savedCreditCards,
refId,
}: ManageStayProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeView, setActiveView] = useState<ActiveView>("actionPanel")
const intl = useIntl()
const showCancelStayButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
const {
isOpen,
activeView,
actions: { setIsOpen, handleCloseModal, setActiveView },
} = useManageStayStore()
const showGuaranteeButton =
bookingStatus !== BookingStatusEnum.Cancelled && !booking.guaranteeInfo
function handleClose() {
setIsOpen(false)
setActiveView("actionPanel")
}
function handleBack() {
setActiveView("actionPanel")
}
function renderContent() {
switch (activeView) {
case "cancelStay":
@@ -66,16 +57,16 @@ export default function ManageStay({
setBookingStatus={() =>
setBookingStatus(BookingStatusEnum.Cancelled)
}
handleCloseModal={handleClose}
handleBackToManageStay={handleBack}
/>
)
case "modifyStay":
return <ModifyStay booking={booking} user={user} />
case "guaranteeLateArrival":
return (
<GuaranteeLateArrival
booking={booking}
handleCloseModal={handleClose}
handleBackToManageStay={handleBack}
handleCloseModal={handleCloseModal}
handleBackToManageStay={() => setActiveView("actionPanel")}
savedCreditCards={savedCreditCards}
refId={refId}
/>
@@ -85,9 +76,9 @@ export default function ManageStay({
<ActionPanel
booking={booking}
hotel={hotel}
bookingStatus={bookingStatus}
onCancelClick={() => setActiveView("cancelStay")}
onGuaranteeClick={() => setActiveView("guaranteeLateArrival")}
showCancelStayButton={showCancelStayButton}
showGuaranteeButton={showGuaranteeButton}
/>
)
@@ -100,7 +91,7 @@ export default function ManageStay({
{intl.formatMessage({ id: "Manage stay" })}
<ChevronDownIcon width={24} height={24} color="burgundy" />
</Button>
<Modal isOpen={isOpen} onToggle={handleClose} withActions hideHeader>
<Modal isOpen={isOpen} onToggle={handleCloseModal} withActions hideHeader>
{renderContent()}
</Modal>
</>

View File

@@ -8,6 +8,7 @@ import { dt } from "@/lib/dt"
import { BookingCodeIcon } from "@/components/Icons"
import CrossCircleIcon from "@/components/Icons/CrossCircle"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
@@ -19,17 +20,19 @@ import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import ManageStay from "../ManageStay"
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import styles from "./referenceCard.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"
import { type CreditCard, type User } from "@/types/user"
interface ReferenceCardProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
user: User | null
savedCreditCards: CreditCard[] | null
refId: string
}
@@ -37,6 +40,7 @@ interface ReferenceCardProps {
export function ReferenceCard({
booking,
hotel,
user,
savedCreditCards,
refId,
}: ReferenceCardProps) {
@@ -44,9 +48,14 @@ export function ReferenceCard({
const intl = useIntl()
const lang = useLang()
const { totalPrice, currencyCode } = useMyStayTotalPriceStore()
const { rooms } = useMyStayRoomDetailsStore()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
const fromDate = rooms[0]
? dt(rooms[0].checkInDate).locale(lang)
: dt(booking.checkInDate).locale(lang)
const toDate = rooms[0]
? dt(rooms[0].checkOutDate).locale(lang)
: dt(booking.checkOutDate).locale(lang)
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
useGuaranteePaymentFailedToast()
@@ -137,7 +146,7 @@ export function ReferenceCard({
{intl.formatMessage({ id: "Check-out" })}
</Caption>
<Caption type="bold" color="uiTextHighContrast">
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${toDate.format("HH:mm")}`}
</Caption>
</div>
<Divider color="primaryLightSubtle" className={styles.divider} />
@@ -147,11 +156,16 @@ export function ReferenceCard({
type="bold"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Total paid" })}
</Caption>
<Caption type="bold" color="uiTextHighContrast">
{formatPrice(intl, totalPrice, currencyCode)}
{intl.formatMessage({ id: "Total" })}
</Caption>
{totalPrice ? (
<Caption type="bold" color="uiTextHighContrast">
{formatPrice(intl, totalPrice, currencyCode)}
</Caption>
) : (
<SkeletonShimmer width="50px" height="18px" />
)}
</div>
{booking?.bookingCode && (
<div className={styles.referenceRow}>
@@ -181,6 +195,7 @@ export function ReferenceCard({
<ManageStay
booking={booking}
hotel={hotel}
user={user}
setBookingStatus={setBookingStatus}
bookingStatus={bookingStatus}
savedCreditCards={savedCreditCards}
@@ -194,15 +209,9 @@ export function ReferenceCard({
</div>
{booking.isModifiable && (
<Caption className={styles.note} color="uiTextHighContrast">
{intl.formatMessage(
{
id: "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.",
},
{
date: fromDate.format("D MMMM"),
time: "18:00",
}
)}
{booking.rateDefinition.generalTerms.map((term) => (
<span key={term}>{term} </span>
))}
</Caption>
)}
</div>

View File

@@ -70,21 +70,23 @@ export async function MyStay({ refId }: { refId: string }) {
supportedCards,
})
const imageSrc =
hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large
return (
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
<Image
className={styles.image}
src={
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large ??
""
}
alt={hotel.name}
fill
/>
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotel.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
@@ -92,6 +94,7 @@ export async function MyStay({ refId }: { refId: string }) {
<ReferenceCard
booking={booking}
hotel={hotel}
user={user}
savedCreditCards={savedCreditCards}
refId={refId}
/>

View File

@@ -0,0 +1,50 @@
import { create } from "zustand"
type ActiveView =
| "actionPanel"
| "cancelStay"
| "modifyStay"
| "guaranteeLateArrival"
interface ManageStayState {
isOpen: boolean
activeView: ActiveView
currentStep: number
isLoading: boolean
actions: {
setIsOpen: (isOpen: boolean) => void
setActiveView: (view: ActiveView) => void
setCurrentStep: (step: number) => void
setIsLoading: (isLoading: boolean) => void
handleForward: () => void
handleCloseView: () => void
handleCloseModal: () => void
}
}
export const useManageStayStore = create<ManageStayState>((set) => ({
isOpen: false,
activeView: "actionPanel",
currentStep: 1,
isLoading: false,
actions: {
setIsOpen: (isOpen) => set({ isOpen }),
setActiveView: (activeView) => set({ activeView }),
setCurrentStep: (currentStep) => set({ currentStep }),
setIsLoading: (isLoading) => set({ isLoading }),
handleForward: () =>
set((state) => ({ currentStep: state.currentStep + 1 })),
handleCloseView: () =>
set({
currentStep: 1,
isLoading: false,
activeView: "actionPanel",
}),
handleCloseModal: () =>
set({
currentStep: 1,
isOpen: false,
activeView: "actionPanel",
}),
},
}))

View File

@@ -1,40 +1,63 @@
import { create } from "zustand"
interface RoomDetails {
export interface RoomDetails {
id: string
hotelId: string
checkInDate: Date
checkOutDate: Date
adults: number
children: string
roomName: string
roomTypeCode: string
rateCode: string
bookingCode: string
isCancelable: boolean
mainRoom: boolean
}
interface MyStayRoomDetailsState {
rooms: RoomDetails[]
// Add a single room's details
addRoomDetails: (room: RoomDetails) => void
actions: {
setRoomDetails: (room: RoomDetails) => void
updateRoomDetails: (room: RoomDetails) => void
}
}
export const useMyStayRoomDetailsStore = create<MyStayRoomDetailsState>(
(set) => ({
rooms: [],
actions: {
setRoomDetails: (room) => {
set((state) => {
// Check if room with this ID already exists
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
addRoomDetails: (room) => {
set((state) => {
// Check if room with this ID already exists
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
if (existingIndex >= 0) {
// Update existing room
newRooms[existingIndex] = room
} else {
// Add new room
newRooms.push(room)
}
if (existingIndex >= 0) {
// Update existing room
newRooms[existingIndex] = room
} else {
// Add new room
newRooms.push(room)
}
return {
rooms: newRooms,
}
})
return {
rooms: newRooms,
}
})
},
updateRoomDetails: (room) => {
set((state) => {
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
if (existingIndex >= 0) {
newRooms[existingIndex] = room
}
return {
rooms: newRooms,
}
})
},
},
})
)

View File

@@ -11,12 +11,13 @@ interface MyStayTotalPriceState {
rooms: RoomPrice[]
totalPrice: number
currencyCode: string
actions: {
// Add a single room price
setRoomPrice: (room: RoomPrice) => void
// Add a single room price
addRoomPrice: (room: RoomPrice) => void
// Get the calculated total
getTotalPrice: () => number
// Get the calculated total
getTotalPrice: () => number
}
}
export const useMyStayTotalPriceStore = create<MyStayTotalPriceState>(
@@ -24,43 +25,44 @@ export const useMyStayTotalPriceStore = create<MyStayTotalPriceState>(
rooms: [],
totalPrice: 0,
currencyCode: "",
actions: {
setRoomPrice: (room) => {
set((state) => {
// Check if room with this ID already exists
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
addRoomPrice: (room) => {
set((state) => {
// Check if room with this ID already exists
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
if (existingIndex >= 0) {
// Update existing room
newRooms[existingIndex] = room
} else {
// Add new room
newRooms.push(room)
}
// Get currency from main booking or first room
const mainRoom = newRooms.find((r) => r.isMainBooking) || newRooms[0]
const currencyCode = mainRoom?.currencyCode || ""
// Calculate total (only same currency for now)
const total = newRooms.reduce((sum, r) => {
if (r.currencyCode === currencyCode) {
return sum + r.totalPrice
if (existingIndex >= 0) {
// Update existing room
newRooms[existingIndex] = room
} else {
// Add new room
newRooms.push(room)
}
return sum
}, 0)
return {
rooms: newRooms,
totalPrice: total,
currencyCode,
}
})
},
// Get currency from main booking or first room
const mainRoom = newRooms.find((r) => r.isMainBooking) || newRooms[0]
const currencyCode = mainRoom?.currencyCode || ""
getTotalPrice: () => {
return get().totalPrice
// Calculate total (only same currency for now)
const total = newRooms.reduce((sum, r) => {
if (r.currencyCode === currencyCode) {
return sum + r.totalPrice
}
return sum
}, 0)
return {
rooms: newRooms,
totalPrice: total,
currencyCode,
}
})
},
getTotalPrice: () => {
return get().totalPrice
},
},
})
)

View File

@@ -0,0 +1,23 @@
export function formatChildBedPreferences({
childrenAges,
childBedPreferences,
}: {
childrenAges: number[]
childBedPreferences: Array<{
bedType: string
quantity: number
code: string | null
}>
}) {
if (childrenAges.length === 0) return ""
const preferences = childrenAges
.map((age, index) => {
const bed = childBedPreferences[index].bedType
if (!bed) return null
return `${age}:${bed}`
})
.filter(Boolean)
return `[${preferences.join(", ")}]`
}

View File

@@ -70,6 +70,8 @@
"Arrival date": "Ankomstdato",
"As our Close Friend": "Som vores nære ven",
"As our {level}": "Som vores {level}",
"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.": "Da dette er et multiroom ophold, gælder alle datoændringer for alle værelser. Bedes du personen, der bookede opholdet, kontakte kundeservice.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "Da dette er et multiroom ophold, gælder alle datoændringer for alle værelser. Bedes du kontakte kundeservice for at opdatere datoerne.",
"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.": "Da dette er et ophold med flere værelser, skal annullereringen gennemføres af personen, der har booket opholdet. Bedes du ringe til vores kundeservice på 08-517 517 00, hvis du har brug for yderligere hjælp.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din booking inkluderer værelser med forskellige vilkår, vi vil blive opkræve en del af bookingen nu og resten vil blive indsamlet ved check-in.",
"At a cost": "Mod betaling",
@@ -147,6 +149,7 @@
"Change room": "Skift værelse",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Ændringer kan gøres indtil {time} på {date}, under forudsætning af tilgængelighed. Priserne for værelserne kan variere.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Tjek tilgængelighed",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Check ind",
"Check in from: {checkInTime}": "Indtjekning fra: {checkInTime}",
@@ -185,6 +188,8 @@
"Confirm": "Bekræft",
"Confirm booking": "Bekræft reservation",
"Confirm cancellation": "Bekræft annullerering",
"Confirm date change": "Bekræft datoændring",
"Contact customer service": "Kontakt kundeservice",
"Contact information": "Kontaktoplysninger",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Kontakt personen, der bookede opholdet",
@@ -262,6 +267,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Fejl ved opdatering af gæstdetaljer",
"Failed to update your stay": "Fejl ved opdatering af dit ophold",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskab ikke verificeret",
"Fair": "Messe",
@@ -475,6 +481,8 @@
"Near elevator": "Tæt på elevator",
"Nearby": "I nærheden",
"Nearby companies": "Nærliggende virksomheder",
"New dates": "Nye datoer",
"New dates for the stay": "Ny datoer for opholdet",
"New password": "Nyt kodeord",
"New total": "Ny total",
"Next": "Næste",
@@ -491,6 +499,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "Ingen tilgængelige priser",
"No results": "Ingen resultater",
"No single rooms are available on these dates": "Ingen enkeltdobbeltrum tilgængelige på disse datoer",
"No transactions available": "Ingen tilgængelige transaktioner",
"No windows": "No windows",
"No windows but excellent lighting": "No windows but excellent lighting",
@@ -505,6 +514,7 @@
"Number: {membershipNumber}": "Number: {membershipNumber}",
"OK": "OK",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"Old dates": "Gamle datoer",
"On your journey": "På din rejse",
"One last step": "Et sidste skridt",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Ups! Noget gik galt under visningen af din overraskelse. Opdater siden, eller prøv igen senere. Hvis problemet fortsætter, skal du <link>kontakte supporten.</link>",
@@ -661,6 +671,7 @@
"Select bed": "Vælg seng",
"Select breakfast options": "Vælg morgenmadsmuligheder",
"Select country of residence": "Vælg bopælsland",
"Select date": "Vælg dato",
"Select date of birth": "Vælg fødselsdato",
"Select dates": "Vælg datoer",
"Select hotel": "Vælg hotel",
@@ -724,6 +735,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Tider",
"To be paid": "At betale",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "For at få medlemsprisen <span>{price}</span>, log ind eller tilmeld dig, når du udfylder bookingen.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "For at sikre din reservation, beder vi om at du giver os dine betalingsoplysninger. Du kan så være sikker på, at ingen gebyrer vil blive opkrævet på dette tidspunkt.",
"Total": "Total",
@@ -849,6 +861,7 @@
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was updated": "Dit ophold blev opdateret",
"Your transaction": "Your transaction",
"Zip code": "Postnummer",
"Zoo": "Zoo",
@@ -870,6 +883,7 @@
"sunday": "søndag",
"thursday": "torsdag",
"tuesday": "tirsdag",
"until": "indtil",
"wednesday": "onsdag",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km til byens centrum",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# voksen} other {# voksne}}",

View File

@@ -70,6 +70,8 @@
"Arrival date": "Ankunftsdatum",
"As our Close Friend": "Als unser enger Freund",
"As our {level}": "Als unser {level}",
"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.": "As this is a multiroom stay, any dates changes are applicable to all rooms. Bitte bitten Sie den Person, der die Buchung gemacht hat, uns zu kontaktieren.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "As this is a multiroom stay, any dates changes are applicable to all rooms. Bitte bitten Sie uns zu kontaktieren, um die Daten zu aktualisieren.",
"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.": "Da dies ein Mehrzimmer-Aufenthalt ist, muss die Stornierung von der Person, die die Buchung getätigt hat, durchgeführt werden. Bitte rufen Sie uns unter der Telefonnummer 08-517 517 00 an, wenn Sie weitere Hilfe benötigen.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Ihre Buchung enthält Zimmer mit unterschiedlichen Bedingungen, wir werden einen Teil der Buchung jetzt belasten und den Rest bei der Anreise durch die Reception erheben.",
"At a cost": "Gegen Gebühr",
@@ -148,6 +150,7 @@
"Change room": "Zimmer ändern",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Änderungen können bis {time} am {date} vorgenommen werden, vorausgesetzt, dass die Zimmer noch verfügbar sind. Die Zimmerpreise können variieren.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Verfügbarkeit prüfen",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Einchecken",
"Check in from: {checkInTime}": "Check-in ab: {checkInTime}",
@@ -186,6 +189,8 @@
"Confirm": "Bestätigen",
"Confirm booking": "Buchung bestätigen",
"Confirm cancellation": "Stornierung bestätigen",
"Confirm date change": "Datenänderung bestätigen",
"Contact customer service": "Kontakt kundeservice",
"Contact information": "Kontaktinformationen",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Kontakt personen, der Buchung getätigt hat",
@@ -263,6 +268,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Fehler beim Aktualisieren der Gästedaten",
"Failed to update your stay": "Fehler beim Aktualisieren Ihres Aufenthalts",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskab nicht verifiziert",
"Fair": "Messe",
@@ -476,6 +482,8 @@
"Near elevator": "In der Nähe des Aufzugs",
"Nearby": "In der Nähe",
"Nearby companies": "Nahe gelegene Unternehmen",
"New dates": "Neue Daten",
"New dates for the stay": "Neue Daten für den Aufenthalt",
"New password": "Neues Kennwort",
"New total": "Neue Gesamt",
"Next": "Nächste",
@@ -492,6 +500,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "Keine Preise verfügbar",
"No results": "Keine Ergebnisse",
"No single rooms are available on these dates": "Keine Einzelzimmer verfügbar für diese Daten",
"No transactions available": "Keine Transaktionen verfügbar",
"No windows": "No windows",
"No windows but excellent lighting": "No windows but excellent lighting",
@@ -506,6 +515,7 @@
"Number: {membershipNumber}": "Number: {membershipNumber}",
"OK": "OK",
"OTHER PAYMENT METHODS": "ANDERE BEZAHLMETHODE",
"Old dates": "Alte Daten",
"On your journey": "Auf deiner Reise",
"One last step": "Ein letzter Schritt",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Ups! Beim Anzeigen Ihrer Überraschung ist ein Fehler aufgetreten. Bitte aktualisieren Sie die Seite oder versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, <link>kontaktieren Sie den Support.</link>",
@@ -660,6 +670,7 @@
"Select bed": "Betttyp auswählen",
"Select breakfast options": "Wählen Sie Frühstücksoptionen",
"Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus",
"Select date": "Datum auswählen",
"Select date of birth": "Geburtsdatum auswählen",
"Select dates": "Datum auswählen",
"Select hotel": "Hotel auswählen",
@@ -722,6 +733,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Zeiten",
"To be paid": "Zu zahlen",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "Um den Mitgliederpreis von <span>{price}</span> zu erhalten, loggen Sie sich ein oder treten Sie Scandic Friends bei, wenn Sie die Buchung abschließen.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Um Ihre Reservierung zu sichern, bitten wir Sie, Ihre Zahlungskarteninformationen zu geben. Sie können sicher sein, dass keine Gebühren zu diesem Zeitpunkt erhoben werden.",
"Total": "Gesamt",
@@ -847,6 +859,7 @@
"Your selected bed type will be provided based on availability": "Ihre ausgewählte Bettart wird basierend auf der Verfügbarkeit bereitgestellt",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Ihr Aufenthalt wurde storniert. Stornierungskosten: 0 {currency}. Es tut uns leid, dass die Pläne nicht funktionierten",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Ihr Aufenthalt wurde storniert. Stornierungskosten: 0 {currency}. Es tut uns leid, dass die Pläne nicht funktionierten",
"Your stay was updated": "Ihr Aufenthalt wurde aktualisiert",
"Your transaction": "Your transaction",
"Zip code": "PLZ",
"Zoo": "Zoo",
@@ -868,6 +881,7 @@
"sunday": "sonntag",
"thursday": "donnerstag",
"tuesday": "dienstag",
"until": "bis",
"wednesday": "mittwoch",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km bis zum Stadtzentrum",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# Erwachsene} other {# Erwachsene}}",

View File

@@ -69,6 +69,8 @@
"Arrival date": "Arrival date",
"As our Close Friend": "As our Close Friend",
"As our {level}": "As our {level}",
"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.": "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.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.",
"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.": "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.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.",
"At a cost": "At a cost",
@@ -146,6 +148,7 @@
"Change room": "Change room",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Check availability",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Check in",
"Check in from: {checkInTime}": "Check in from: {checkInTime}",
@@ -184,6 +187,8 @@
"Confirm": "Confirm",
"Confirm booking": "Confirm booking",
"Confirm cancellation": "Confirm cancellation",
"Confirm date change": "Confirm date change",
"Contact customer service": "Contact customer service",
"Contact information": "Contact information",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Contact the person who booked the stay",
@@ -261,6 +266,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Failed to update guest details",
"Failed to update your stay": "Failed to update your stay",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Failed to verify membership",
"Fair": "Fair",
@@ -474,6 +480,8 @@
"Near elevator": "Near elevator",
"Nearby": "Nearby",
"Nearby companies": "Nearby companies",
"New dates": "New dates",
"New dates for the stay": "New dates for the stay",
"New password": "New password",
"New total": "New total",
"Next": "Next",
@@ -490,6 +498,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "No prices available",
"No results": "No results",
"No single rooms are available on these dates": "No single rooms are available on these dates",
"No transactions available": "No transactions available",
"No windows": "No windows",
"No windows but excellent lighting": "No windows but excellent lighting",
@@ -505,6 +514,7 @@
"OK": "OK",
"OTHER": "OTHER",
"OTHER PAYMENT METHODS": "OTHER PAYMENT METHODS",
"Old dates": "Old dates",
"On your journey": "On your journey",
"One last step": "One last step",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>",
@@ -659,6 +669,7 @@
"Select bed": "Select bed",
"Select breakfast options": "Select breakfast options",
"Select country of residence": "Select country of residence",
"Select date": "Select date",
"Select date of birth": "Select date of birth",
"Select dates": "Select dates",
"Select hotel": "Select hotel",
@@ -721,6 +732,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Times",
"To be paid": "To be paid",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "To get the member price <span>{price}</span>, log in or join when completing the booking.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
"Total": "Total",
@@ -845,6 +857,7 @@
"Your selected bed type will be provided based on availability": "Your selected bed type will be provided based on availability",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out",
"Your stay was updated": "Your stay was updated",
"Your transaction": "Your transaction",
"Zip code": "Zip code",
"Zoo": "Zoo",
@@ -863,6 +876,7 @@
"sunday": "sunday",
"thursday": "thursday",
"tuesday": "tuesday",
"until": "until",
"wednesday": "wednesday",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km to city center",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# adult} other {# adults}}",

View File

@@ -69,6 +69,8 @@
"Arrival date": "Saapumispäivä",
"As our Close Friend": "Läheisenä ystävänämme",
"As our {level}": "{level}-etu",
"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.": "Tämä on monihuoneinen majoitus, joten kaikki päivämäärämuutokset koskevat kaikkia huoneita. Pyydämme henkilöä, joka teki varauksen, ottamaan yhteyttä asiakaspalveluun.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "Tämä on monihuoneinen majoitus, joten kaikki päivämäärämuutokset koskevat kaikkia huoneita. Pyydämme asiakaspalvelua päivämäärämuutoksia varten.",
"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.": "Koska tämä on monihuoneinen majoitus, peruutus on tehtävä henkilölle, joka teki varauksen. Ota yhteyttä asiakaspalveluun apua varten, jos tarvitset lisää apua.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Sinun varauksessasi on huoneita eri hinnoilla, me veloitamme osan varauksesta nyt ja loput tarkistukseen tapahtuvan tarkistuksen yhteydessä.",
"At a cost": "Maksua vastaan",
@@ -146,6 +148,7 @@
"Change room": "Vaihda huonetta",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Muutoksia voi tehdä {time} päivänä {date}, olettaen saatavuuden olemassaolon. Huonehinnat voivat muuttua.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Tarkista saatavuus",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Sisäänkirjautuminen",
"Check in from: {checkInTime}": "Sisäänkirjautuminen alkaen: {checkInTime}",
@@ -185,6 +188,8 @@
"Confirm": "Vahvista",
"Confirm booking": "Vahvista varaus",
"Confirm cancellation": "Vahvista peruutus",
"Confirm date change": "Vahvista päivämäärän muutos",
"Contact customer service": "Ota yhteyttä asiakaspalveluun",
"Contact information": "Yhteystiedot",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Ota yhteyttä henkilölle, joka teki varauksen",
@@ -262,6 +267,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Gästien tiedojen päivitys epäonnistui",
"Failed to update your stay": "Majoituspäivät eivät päivitetty",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Jäsenyys ei verifioitu",
"Fair": "Messukeskus",
@@ -475,6 +481,8 @@
"Near elevator": "Lähellä hissiä",
"Nearby": "Lähistöllä",
"Nearby companies": "Läheiset yritykset",
"New dates": "Uudet päivämäärät",
"New dates for the stay": "Uudet päivät",
"New password": "Uusi salasana",
"New total": "Uusi kokonais",
"Next": "Seuraava",
@@ -491,6 +499,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "Hintoja ei ole saatavilla",
"No results": "Ei tuloksia",
"No single rooms are available on these dates": "Ei yksinäisiä huoneita saatavilla näillä päivämäärillä",
"No transactions available": "Ei tapahtumia saatavilla",
"No windows": "No windows",
"No windows but excellent lighting": "No windows but excellent lighting",
@@ -505,6 +514,7 @@
"Number: {membershipNumber}": "Number: {membershipNumber}",
"OK": "OK",
"OTHER PAYMENT METHODS": "MUISE KORT",
"Old dates": "Vanhat päivämäärät",
"On your journey": "Matkallasi",
"One last step": "Viimeinen askel",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Hups! Jotain meni pieleen yllätyksesi näyttämisessä. Päivitä sivu tai yritä myöhemmin uudelleen. Jos ongelma jatkuu, <link>ota yhteyttä tukeen.</link>",
@@ -660,6 +670,7 @@
"Select bed": "Valitse vuodetyyppi",
"Select breakfast options": "Valitse aamiaisvaihtoehdot",
"Select country of residence": "Valitse asuinmaa",
"Select date": "Valitse päivä",
"Select date of birth": "Valitse syntymäaika",
"Select dates": "Valitse päivämäärät",
"Select hotel": "Valitse hotelli",
@@ -722,6 +733,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Ajat",
"To be paid": "Maksettava",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "Jäsenhintaan saavat sisäänkirjautuneet tai liittyneet jäsenet.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Varmistaaksesi varauksen, pyydämme sinua antamaan meille maksukortin tiedot. Varmista, että ei veloiteta maksusi tällä hetkellä.",
"Total": "Kokonais",
@@ -847,6 +859,7 @@
"Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Majoituksesi peruutettiin. Peruutusmaksu: 0 {currency}. Emme voi käyttää sitä, että suunnitellut majoitukset eivät toiminneet",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Majoituksesi peruutettiin. Peruutusmaksu: 0 {currency}. Emme voi käyttää sitä, että suunnitellut majoitukset eivät toiminneet",
"Your stay was updated": "Majoituspäivät päivitettiin",
"Your transaction": "Your transaction",
"Zip code": "Postinumero",
"Zoo": "Eläintarha",
@@ -868,6 +881,7 @@
"sunday": "sunnuntai",
"thursday": "torstai",
"tuesday": "tiistai",
"until": "asti",
"wednesday": "keskiviikko",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km keskustaan",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# vieras} other {# vieraita}}",

View File

@@ -69,6 +69,8 @@
"Arrival date": "Ankomstdato",
"As our Close Friend": "Som vår nære venn",
"As our {level}": "Som vår {level}",
"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.": "Som dette er et multiroom ophold, gælder alle datoændringer for alle værelser. Bedes du personen, der bookede opholdet, kontakte kundeservice.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "Som dette er et multiroom ophold, gælder alle datoændringer for alle værelser. Bedes du kontakte kundeservice for at opdatere datoerne.",
"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.": "Som dette er et ophold med flere rom, må annullereringen gjøres av personen som booket opholdet. Vennligst ring 08-517 517 00 til vår kundeservice hvis du trenger mer hjelp.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din bestilling inkluderer rom med ulike vilkår, vi vil belaste en del av bestillingen nå og den resterende vil bli samlet inn ved check-in.",
"At a cost": "Mot betaling",
@@ -146,6 +148,7 @@
"Change room": "Endre rom",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Endringer kan gjøres til {time} på {date}, under forutsetning av tilgjengelighet. Rompriser kan variere.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Sjekk tilgjengelighet",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Sjekk inn",
"Check in from: {checkInTime}": "Innsjekking fra: {checkInTime}",
@@ -184,6 +187,8 @@
"Confirm": "Bekreft",
"Confirm booking": "Bekreft bestilling",
"Confirm cancellation": "Bekræft annullerering",
"Confirm date change": "Bekreft datoendring",
"Contact customer service": "Kontakt kundeservice",
"Contact information": "Kontaktinformasjon",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Kontakt personen som booket opholdet",
@@ -261,6 +266,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Feil ved oppdatering av gjestens detaljer",
"Failed to update your stay": "Kunne ikke oppdatere ditt ophold",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskap ikke verifisert",
"Fair": "Messe",
@@ -474,6 +480,8 @@
"Near elevator": "Nær heisen",
"Nearby": "I nærheten",
"Nearby companies": "Nærliggende selskaper",
"New dates": "Nye datoer",
"New dates for the stay": "Nye datoer for opholdet",
"New password": "Nytt passord",
"New total": "Ny total",
"Next": "Neste",
@@ -490,6 +498,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "Ingen priser tilgjengelig",
"No results": "Ingen resultater",
"No single rooms are available on these dates": "Ingen enkeltdobbeltrum tilgjengelige på disse datoene",
"No transactions available": "Ingen transaksjoner tilgjengelig",
"No windows": "No windows",
"No windows but excellent lighting": "No windows but excellent lighting",
@@ -504,6 +513,7 @@
"Number: {membershipNumber}": "Number: {membershipNumber}",
"OK": "OK",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"Old dates": "Gamle datoer",
"On your journey": "På reisen din",
"One last step": "Et siste skritt",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Beklager! Noe gikk galt under visningen av overraskelsen din. Oppdater siden eller prøv igjen senere. Hvis problemet vedvarer, <link>kontakt brukerstøtten.</link>",
@@ -657,6 +667,7 @@
"Select bed": "Vælg seng",
"Select breakfast options": "Velg frokostalternativer",
"Select country of residence": "Velg bostedsland",
"Select date": "Velg dato",
"Select date of birth": "Velg fødselsdato",
"Select dates": "Velg datoer",
"Select hotel": "Velg hotell",
@@ -719,6 +730,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Tider",
"To be paid": "Å betale",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "For å få medlemsprisen <span>{price}</span>, logg inn eller bli med når du fullfører bestillingen.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "For å sikre din reservasjon, ber vi om at du gir oss dine betalingskortdetaljer. Vær sikker på at ingen gebyrer vil bli belastet på dette tidspunktet.",
"Total": "Total",
@@ -843,6 +855,7 @@
"Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Ditt ophold ble annulleret. Annullereringspris: 0 {currency}. Vi beklager at planene ikke fungerte ut",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud",
"Your stay was updated": "Ditt ophold ble oppdatert",
"Your transaction": "Your transaction",
"Zip code": "Post kode",
"Zoo": "Dyrehage",
@@ -864,6 +877,7 @@
"sunday": "søndag",
"thursday": "torsdag",
"tuesday": "tirsdag",
"until": "til",
"wednesday": "onsdag",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km til sentrum",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# voksen} other {# voksne}}",

View File

@@ -69,6 +69,8 @@
"Arrival date": "Ankomstdatum",
"As our Close Friend": "Som vår nära vän",
"As our {level}": "Som vår {level}",
"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.": "Då detta är ett multiroom uppehåll, gäller alla datumändringar för alla rum. Vänligen be personen som bokade uppehållet kontakta kundtjänsten.",
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.": "Då detta är ett multiroom uppehåll, gäller alla datumändringar för alla rum. Vänligen kontakta kundtjänsten för att uppdatera datum.",
"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.": "Då detta är en vistelse med flera rum måste avbokningen göras av personen som bokade vistelsen. Kontakta vår kundsupport på 08-517 517 00 om du behöver mer hjälp.",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din bokning innehåller rum med olika villkor, vi kommer att debitera en del av bokningen nu och resten kommer att samlas in vid check-in.",
"At a cost": "Mot en kostnad",
@@ -146,6 +148,7 @@
"Change room": "Ändra rum",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Ändringar kan göras tills {time} den {date}, under förutsättning av tillgänglighet. Priserna för rummen kan variera.",
"Changes in tier match can take up to 24 hours to be displayed.": "Changes in tier match can take up to 24 hours to be displayed.",
"Check availability": "Kontrollera tillgänglighet",
"Check for level upgrade": "Check for level upgrade",
"Check in": "Checka in",
"Check in from: {checkInTime}": "Incheckning från: {checkInTime}",
@@ -184,6 +187,8 @@
"Confirm": "Bekräfta",
"Confirm booking": "Bekräfta bokning",
"Confirm cancellation": "Bekräfta avbokning",
"Confirm date change": "Bekräfta datumändring",
"Contact customer service": "Kontakta kundtjänsten",
"Contact information": "Kontaktinformation",
"Contact our memberservice": "Contact our memberservice",
"Contact the person who booked the stay": "Kontakta personen som bokade vistelsen",
@@ -261,6 +266,7 @@
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Misslyckades att uppdatera gästdetaljer",
"Failed to update your stay": "Misslyckades att uppdatera din vistelse",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskap inte verifierat",
"Fair": "Mässa",
@@ -474,6 +480,8 @@
"Near elevator": "Nära hissen",
"Nearby": "I närheten",
"Nearby companies": "Närliggande företag",
"New dates": "Nya datum",
"New dates for the stay": "Nya datum för vistelsen",
"New password": "Nytt lösenord",
"New total": "Ny total",
"Next": "Nästa",
@@ -490,6 +498,7 @@
"No membership benefits applied": "No membership benefits applied",
"No prices available": "Inga priser tillgängliga",
"No results": "Inga resultat",
"No single rooms are available on these dates": "Inga enkelsängsrum tillgängliga på dessa datum",
"No transactions available": "Inga transaktioner tillgängliga",
"No windows": "Inga fönster",
"No windows but excellent lighting": "Inga fönster men utmärkt belysning",
@@ -504,6 +513,7 @@
"Number: {membershipNumber}": "Number: {membershipNumber}",
"OK": "OK",
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
"Old dates": "Gamla datum",
"On your journey": "På din resa",
"One last step": "Ett sista steg",
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Hoppsan! Något gick fel när din överraskning visades. Uppdatera sidan eller försök igen senare. Om problemet kvarstår, <link>kontakta supporten.</link>",
@@ -658,6 +668,7 @@
"Select bed": "Välj säng",
"Select breakfast options": "Välj frukostalternativ",
"Select country of residence": "Välj bosättningsland",
"Select date": "Välj datum",
"Select date of birth": "Välj födelsedatum",
"Select dates": "Välj datum",
"Select hotel": "Välj hotell",
@@ -720,6 +731,7 @@
"Tier match status": "Tier match status",
"Tier status": "Tier status",
"Times": "Tider",
"To be paid": "Att betala",
"To get the member price <span>{price}</span>, log in or join when completing the booking.": "För att få medlemsprisen <span>{price}</span>, logga in eller bli medlem när du slutför bokningen.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "För att säkra din bokning ber vi om att du ger oss dina betalkortdetaljer. Välj säker på att ingen avgifter kommer att debiteras just nu.",
"Total": "Totalt",
@@ -845,6 +857,7 @@
"Your selected bed type will be provided based on availability": "Din valda sängtyp kommer att tillhandahållas baserat på tillgänglighet",
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out": "Din vistelse blev avbokad. Avbokningskostnad: 0 {currency}. Vi beklagar att planerna inte fungerade.",
"Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out": "Din vistelse blev avbokad. Avbokningskostnad: 0 {currency}. Vi beklagar att planerna inte fungerade ut",
"Your stay was updated": "Din vistelse uppdaterades",
"Your transaction": "Your transaction",
"Zip code": "Postnummer",
"Zoo": "Djurpark",
@@ -868,6 +881,7 @@
"tuesday": "tisdag",
"type": "typ",
"types": "typer",
"until": "tills",
"wednesday": "onsdag",
"{address}, {city} ∙ {distanceToCityCenterInKm} km to city center": "{address}, {city} ∙ {distanceToCityCenterInKm} km till stadens centrum",
"{adults, plural, one {# adult} other {# adults}}": "{adults, plural, one {# vuxen} other {# vuxna}}",

View File

@@ -51,7 +51,6 @@ const addPackageSuccessCounter = meter.createCounter(
const addPackageFailCounter = meter.createCounter(
"trpc.bookings.add-package-fail"
)
const guaranteeBookingCounter = meter.createCounter("trpc.bookings.guarantee")
const guaranteeBookingSuccessCounter = meter.createCounter(
"trpc.bookings.guarantee-success"
@@ -59,13 +58,12 @@ const guaranteeBookingSuccessCounter = meter.createCounter(
const guaranteeBookingFailCounter = meter.createCounter(
"trpc.bookings.guarantee-fail"
)
const updateGuestCounter = meter.createCounter("trpc.bookings.update-guest")
const updateGuestSuccessCounter = meter.createCounter(
"trpc.bookings.update-guest-success"
const updateBookingCounter = meter.createCounter("trpc.bookings.update-booking")
const updateBookingSuccessCounter = meter.createCounter(
"trpc.bookings.update-booking-success"
)
const updateGuestFailCounter = meter.createCounter(
"trpc.bookings.update-guest-fail"
const updateBookingFailCounter = meter.createCounter(
"trpc.bookings.update-booking-fail"
)
const removePackageCounter = meter.createCounter("trpc.bookings.remove-package")
@@ -485,7 +483,7 @@ export const bookingMutationRouter = router({
const accessToken = ctx.serviceToken
const { confirmationNumber, ...body } = input
updateGuestCounter.add(1, { confirmationNumber })
updateBookingCounter.add(1, { confirmationNumber })
const headers = {
Authorization: `Bearer ${accessToken}`,
@@ -501,7 +499,7 @@ export const bookingMutationRouter = router({
if (!apiResponse.ok) {
const text = await apiResponse.text()
updateGuestFailCounter.add(1, {
updateBookingFailCounter.add(1, {
confirmationNumber,
error_type: "http_error",
error: JSON.stringify({
@@ -509,7 +507,7 @@ export const bookingMutationRouter = router({
}),
})
console.error(
"api.booking.updateGuest error",
"api.booking.updateBooking error",
JSON.stringify({
query: { confirmationNumber },
error: {
@@ -523,14 +521,16 @@ export const bookingMutationRouter = router({
}
const apiJson = await apiResponse.json()
console.log("apiJson", apiJson)
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
updateGuestFailCounter.add(1, {
updateBookingFailCounter.add(1, {
confirmationNumber,
error_type: "validation_error",
})
console.error(
"api.booking.updateGuest validation error",
"api.booking.updateBooking validation error",
JSON.stringify({
query: { confirmationNumber },
error: verifiedData.error,
@@ -539,7 +539,7 @@ export const bookingMutationRouter = router({
return null
}
updateGuestSuccessCounter.add(1, { confirmationNumber })
updateBookingSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),

View File

@@ -12,6 +12,8 @@ export const createBookingSchema = z
data: z.object({
attributes: z.object({
reservationStatus: z.string(),
checkInDate: z.string(),
checkOutDate: z.string(),
paymentUrl: z.string().nullable().optional(),
rooms: z
.array(
@@ -61,6 +63,8 @@ export const createBookingSchema = z
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
rooms: d.data.attributes.rooms,
checkInDate: d.data.attributes.checkInDate,
checkOutDate: d.data.attributes.checkOutDate,
errors: d.data.attributes.errors,
}))

View File

@@ -58,6 +58,7 @@ export const selectedRoomAvailabilityInputSchema = z.object({
roomTypeCode: z.string(),
counterRateCode: z.string().optional(),
packageCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
lang: z.nativeEnum(Lang).optional(),
})
export type GetSelectedRoomAvailabilityInput = z.input<

View File

@@ -587,6 +587,8 @@ export const hotelQueryRouter = router({
room: serviceProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = input
const {
hotelId,
roomStayStartDate,
@@ -605,7 +607,7 @@ export const hotelQueryRouter = router({
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: toApiLang(ctx.lang),
language: lang ?? toApiLang(ctx.lang),
}
metrics.selectedRoomAvailability.counter.add(1, {
@@ -688,7 +690,7 @@ export const hotelQueryRouter = router({
{
hotelId,
isCardOnlyPayment: false,
language: ctx.lang,
language: lang ?? ctx.lang,
},
ctx.serviceToken
)

View File

@@ -1,17 +1,27 @@
import { Lang } from "@/constants/languages"
import type { Locale } from "date-fns"
import type { DateRange } from "react-day-picker"
import type { Lang } from "@/constants/languages"
export interface DatePickerFormProps {
name?: string
}
type LangWithoutEn = Lang.da | Lang.de | Lang.fi | Lang.no | Lang.sv
export interface DatePickerProps {
interface DatePickerProps {
close: () => void
handleOnSelect: (selected: Date) => void
locales: Record<LangWithoutEn, Locale>
selectedDate: DateRange | Date
startMonth?: Date
hideHeader?: boolean
}
export interface DatePickerRangeProps extends DatePickerProps {
selectedDate: DateRange
}
export interface DatePickerSingleProps extends DatePickerProps {
selectedDate: Date
}

View File

@@ -18,10 +18,9 @@ export interface CancelStayProps {
hotel: Hotel
setBookingStatus: () => void
handleCloseModal: () => void
handleBackToManageStay: () => void
}
export type FormValues = z.infer<typeof cancelStaySchema>
export type CancelStayFormValues = z.infer<typeof cancelStaySchema>
export interface RoomDetails {
id: string

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { User } from "@/types/user"
export const modifyDateSchema = z.object({
checkInDate: z.string(),
checkOutDate: z.string(),
})
export type ModifyDateSchema = z.infer<typeof modifyDateSchema>
export interface QueryInput {
hotelId: string
roomStayStartDate: string
roomStayEndDate: string
adults: number
children: string
bookingCode: string
rateCode: string
roomTypeCode: string
lang: Lang
}
export const DEFAULT_QUERY_INPUT: QueryInput = {
hotelId: "",
roomStayStartDate: "",
roomStayEndDate: "",
adults: 1,
children: "",
bookingCode: "",
rateCode: "",
roomTypeCode: "",
lang: Lang.en,
}
export interface ModifyStayProps {
booking: BookingConfirmation["booking"]
user: User | null
}