Merged in feat/SW-1737-design-mystay-multiroom (pull request #1565)
Feat/SW-1737 design mystay multiroom * feat(SW-1737) Fixed member view of guest details * feat(SW-1737) fix merge issues * feat(SW-1737) Fixed price details * feat(SW-1737) removed unused imports * feat(SW-1737) removed true as statement * feat(SW-1737) updated store handling * feat(SW-1737) fixed bug showing double numbers * feat(SW-1737) small design fixed * feat(SW-1737) fixed rebase errors * feat(SW-1737) fixed create booking error with dates * feat(SW-1737) fixed view multiroom as singleroom * feat(SW-1737) fixes for multiroom * feat(SW-1737) fixed bookingsummary * feat(SW-1737) dont hide modify dates * feat(SW-1737) updated breakfast to handle number * feat(SW-1737) Added red color if member rate * feat(SW-1737) fix PR comments * feat(SW-1737) updated member tiers svg * feat(SW-1737) updated how to handle paymentMethodDescription * feat(SW-1737) fixes after testing mystay * feat(SW-1737) updated Room type to just use whats used * feat(SW-1737) fixed access * feat(SW-1737) refactor my stay after PR comments * feat(SW-1737) fix roomNumber translation * feat(SW-1737) removed log Approved-by: Arvid Norlin
This commit is contained in:
@@ -10,6 +10,11 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.ancillaries {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./summaryCard.module.css"
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
title: React.ReactNode
|
||||
image: {
|
||||
src: string
|
||||
alt: string
|
||||
@@ -36,25 +36,28 @@ export default function SummaryCard({
|
||||
<Image src={image.src} alt={image.alt} width={152} height={152} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.topContent}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{title}
|
||||
</Body>
|
||||
<div>
|
||||
{title}
|
||||
{texts.map((text) => (
|
||||
<Body color="uiTextHighContrast" key={text}>
|
||||
{text}
|
||||
</Body>
|
||||
<Typography variant="Body/Paragraph/mdRegular" key={text}>
|
||||
<p>{text}</p>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
{supportingText && (
|
||||
<Caption color="uiTextPlaceholder">{supportingText}</Caption>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.supportingText}>{supportingText}</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.bottomContent}>
|
||||
{chip}
|
||||
{links && (
|
||||
<div className={styles.links}>
|
||||
{links.map((link) => (
|
||||
<Caption asChild type="bold" color="burgundy" key={link.href}>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
key={link.href}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
@@ -65,7 +68,7 @@ export default function SummaryCard({
|
||||
{link.icon}
|
||||
{link.text}
|
||||
</Link>
|
||||
</Caption>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,34 +5,18 @@
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 152px;
|
||||
height: 152px;
|
||||
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.image {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topContent {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.bottomContent {
|
||||
@@ -50,3 +34,22 @@
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.supportingText {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.image {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bookingSummaryContent {
|
||||
@@ -10,13 +11,26 @@
|
||||
gap: 80px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingSummaryContent {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingSummary {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookingSummaryContent {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { CancellationRuleEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
@@ -13,120 +16,107 @@ import {
|
||||
} from "@/components/Icons"
|
||||
import CrossCircleIcon from "@/components/Icons/CrossCircle"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { Toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import { trackMyStayPageLink } from "@/utils/tracking"
|
||||
|
||||
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
|
||||
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
|
||||
import { formatChildBedPreferences } from "../utils"
|
||||
import TotalPrice from "../Rooms/TotalPrice"
|
||||
import SummaryCard from "./SummaryCard"
|
||||
|
||||
import styles from "./bookingSummary.module.css"
|
||||
|
||||
import type { Hotel, Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
|
||||
interface BookingSummaryProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
room: Room | null
|
||||
}
|
||||
|
||||
export default function BookingSummary({
|
||||
booking,
|
||||
hotel,
|
||||
room,
|
||||
}: BookingSummaryProps) {
|
||||
export default function BookingSummary({ hotel }: BookingSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const {
|
||||
totalPrice,
|
||||
currencyCode,
|
||||
actions: { setRoomPrice },
|
||||
} = useMyStayTotalPriceStore()
|
||||
const {
|
||||
actions: { setRoomDetails },
|
||||
} = useMyStayRoomDetailsStore()
|
||||
|
||||
const childrenAsString = formatChildBedPreferences({
|
||||
childrenAges: booking.childrenAges,
|
||||
childBedPreferences: booking.childBedPreferences,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Add price information
|
||||
setRoomPrice({
|
||||
id: booking.confirmationNumber,
|
||||
totalPrice: booking.totalPrice,
|
||||
currencyCode: booking.currencyCode,
|
||||
isMainBooking: true,
|
||||
})
|
||||
|
||||
// Add room details
|
||||
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, childrenAsString, setRoomPrice, setRoomDetails])
|
||||
isCancelled,
|
||||
createDateTime,
|
||||
rateDefinition,
|
||||
guaranteeInfo,
|
||||
checkInDate,
|
||||
} = bookedRoom
|
||||
|
||||
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
|
||||
|
||||
const bookingDate = dt(createDateTime).locale(lang).format("D MMMM YYYY")
|
||||
|
||||
const isPaid =
|
||||
booking.rateDefinition.cancellationRule !== "CancellableBefore6PM"
|
||||
const bookingDate = dt(booking.createDateTime)
|
||||
.locale(lang)
|
||||
.format("D MMMM YYYY")
|
||||
rateDefinition.cancellationRule !==
|
||||
CancellationRuleEnum.CancellableBefore6PM ||
|
||||
dt(checkInDate).startOf("day").isBefore(dt().startOf("day"))
|
||||
|
||||
const paymentMethod = guaranteeInfo?.paymentMethodDescription
|
||||
?.toLocaleLowerCase()
|
||||
.startsWith("visa")
|
||||
? intl.formatMessage({ id: "Card" })
|
||||
: guaranteeInfo?.paymentMethodDescription
|
||||
? guaranteeInfo?.paymentMethodDescription
|
||||
: intl.formatMessage({ id: "N/A" })
|
||||
|
||||
return (
|
||||
<div className={styles.bookingSummary}>
|
||||
<Subtitle textTransform="uppercase" color="burgundy">
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</Subtitle>
|
||||
<Typography variant="Title/sm">
|
||||
<h2 className={styles.title}>
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</h2>
|
||||
</Typography>
|
||||
<div className={styles.bookingSummaryContent}>
|
||||
<SummaryCard
|
||||
title={formatPrice(intl, totalPrice, currencyCode)}
|
||||
title={<TotalPrice variant="Body/Paragraph/mdBold" />}
|
||||
image={{
|
||||
src: "/_static/img/scandic-coin.svg",
|
||||
alt: "Scandic coin",
|
||||
}}
|
||||
texts={[`${intl.formatMessage({ id: "Payment" })}: N/A`]}
|
||||
texts={[`${intl.formatMessage({ id: "Payment" })}: ${paymentMethod}`]}
|
||||
supportingText={bookingDate}
|
||||
chip={
|
||||
<IconChip
|
||||
color={isPaid ? "green" : "red"}
|
||||
icon={
|
||||
isPaid ? (
|
||||
<CheckCircleIcon width={20} height={20} color="green" />
|
||||
) : (
|
||||
<CrossCircleIcon width={20} height={20} color="red" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Caption color={isPaid ? "green" : "red"}>
|
||||
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
|
||||
{isPaid
|
||||
? intl.formatMessage({ id: "Paid" })
|
||||
: intl.formatMessage({ id: "Unpaid" })}
|
||||
</Caption>
|
||||
</IconChip>
|
||||
isCancelled ? (
|
||||
<IconChip
|
||||
color={"red"}
|
||||
icon={<CrossCircleIcon width={20} height={20} color="red" />}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{intl.formatMessage({ id: "Cancelled" })}</span>
|
||||
</Typography>
|
||||
</IconChip>
|
||||
) : (
|
||||
<IconChip
|
||||
color={isPaid ? "green" : "red"}
|
||||
icon={
|
||||
isPaid ? (
|
||||
<CheckCircleIcon width={20} height={20} color="green" />
|
||||
) : (
|
||||
<CrossCircleIcon width={20} height={20} color="red" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
|
||||
{isPaid
|
||||
? intl.formatMessage({ id: "Paid" })
|
||||
: intl.formatMessage({ id: "Unpaid" })}
|
||||
</span>
|
||||
</Typography>
|
||||
</IconChip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryCard
|
||||
title={hotel.name}
|
||||
title={
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{hotel.name}</p>
|
||||
</Typography>
|
||||
}
|
||||
image={{
|
||||
src: "/_static/img/scandic-service.svg",
|
||||
alt: "Scandic service",
|
||||
@@ -170,7 +160,9 @@ export default function BookingSummary({
|
||||
<ul>
|
||||
{hotel.specialAlerts.map((alert) => (
|
||||
<li key={alert.id}>
|
||||
<Body color="uiTextHighContrast">{alert.text}</Body>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{alert.text}</span>
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -5,13 +5,15 @@ import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BookingStatusEnum, PaymentMethodEnum } from "@/constants/booking"
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
@@ -31,27 +33,24 @@ import { type GuaranteeFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./guaranteeLateArrival.module.css"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { CreditCard } from "@/types/user"
|
||||
|
||||
export interface GuaranteeLateArrivalProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
handleCloseModal: () => void
|
||||
handleBackToManageStay: () => void
|
||||
savedCreditCards: CreditCard[] | null
|
||||
refId: string
|
||||
}
|
||||
|
||||
export default function GuaranteeLateArrival({
|
||||
booking,
|
||||
handleCloseModal,
|
||||
handleBackToManageStay,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: GuaranteeLateArrivalProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const {
|
||||
actions: { handleCloseView, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const methods = useForm<GuaranteeFormData>({
|
||||
defaultValues: {
|
||||
@@ -67,7 +66,7 @@ export default function GuaranteeLateArrival({
|
||||
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
|
||||
|
||||
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
confirmationNumber: bookedRoom.confirmationNumber,
|
||||
handleBookingCompleted: router.refresh,
|
||||
})
|
||||
|
||||
@@ -83,7 +82,7 @@ export default function GuaranteeLateArrival({
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
if (booking.confirmationNumber) {
|
||||
if (bookedRoom.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
@@ -92,7 +91,7 @@ export default function GuaranteeLateArrival({
|
||||
}
|
||||
: undefined
|
||||
guaranteeBooking.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
confirmationNumber: bookedRoom.confirmationNumber,
|
||||
language: lang,
|
||||
...(card !== undefined && { card }),
|
||||
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`,
|
||||
@@ -182,7 +181,7 @@ export default function GuaranteeLateArrival({
|
||||
</div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Body textTransform="bold">
|
||||
{formatPrice(intl, 0, booking.currencyCode)}
|
||||
{formatPrice(intl, 0, bookedRoom.currencyCode)}
|
||||
</Body>
|
||||
</div>
|
||||
</>
|
||||
@@ -194,7 +193,7 @@ export default function GuaranteeLateArrival({
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: intl.formatMessage({ id: "Back" }),
|
||||
onClick: handleBackToManageStay,
|
||||
onClick: handleCloseView,
|
||||
intent: "text",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.guestDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: var(--Main-Brand-PalePeach);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.memberLevel {
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.memberLevelIcon {
|
||||
height: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
width: 80%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.totalPointsText {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.guest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.memberNumber {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
.contactInfoMobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contactInfoDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guest {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.memberLevel {
|
||||
height: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.userDetails {
|
||||
width: 100%;
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.guestDetails {
|
||||
align-items: flex-start;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
.contactInfoMobile,
|
||||
.userDetailsTitle {
|
||||
display: none;
|
||||
}
|
||||
.contactInfoDesktop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
.totalPoints {
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { type Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { DiamondIcon, EditIcon } from "@/components/Icons"
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./guestDetails.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface GuestDetailsProps {
|
||||
user: User | null
|
||||
booking: Room
|
||||
updateRoom: (room: Room) => void
|
||||
}
|
||||
|
||||
export default function GuestDetails({
|
||||
user,
|
||||
booking,
|
||||
updateRoom,
|
||||
}: GuestDetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: booking.guest.firstName,
|
||||
lastName: booking.guest.lastName,
|
||||
email: booking.guest.email,
|
||||
phoneNumber: booking.guest.phoneNumber,
|
||||
countryCode: booking.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const isMemberBooking =
|
||||
booking.guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
toast.error(
|
||||
intl.formatMessage({ id: "Failed to update guest details" })
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateRoom({
|
||||
...booking,
|
||||
guest: {
|
||||
...booking.guest,
|
||||
email: data.guest.email,
|
||||
phoneNumber: data.guest.phoneNumber,
|
||||
countryCode: data.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
toast.success(intl.formatMessage({ id: "Guest details updated" }))
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(intl.formatMessage({ id: "Failed to update guest details" }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.pathname,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.guestDetails}>
|
||||
{isMemberBooking && user.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userDetailsTitle}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Your member tier" })}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.memberLevel}>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
rows={1}
|
||||
className={styles.memberLevelIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
<div className={styles.totalPointsText}>
|
||||
<DiamondIcon color="uiTextHighContrast" />
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Total points" })}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.membership.currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{booking.guest.firstName} {booking.guest.lastName}
|
||||
</p>
|
||||
</Typography>
|
||||
{isMemberBooking && user.membership && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.memberNumber}>
|
||||
{intl.formatMessage(
|
||||
{ id: "Member no. {nr}" },
|
||||
{
|
||||
nr: user.membership.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.contactInfoMobile}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.contactInfoDesktop}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={"secondary"}
|
||||
onClick={handleModifyMemberDetails}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon color="burgundy" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{intl.formatMessage({ id: "Modify guest details" })}</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon color="burgundy" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{intl.formatMessage({ id: "Modify guest details" })}</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
{isModifyGuestDetailsOpen && (
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({ id: "Modify guest details" })}
|
||||
>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({ id: "Modify guest details" })}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
booking.guest && (
|
||||
<ModifyContact
|
||||
guest={booking.guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({ id: "Save updates" })
|
||||
: intl.formatMessage({ id: "Confirm" }),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: () => form.handleSubmit(onSubmit)(),
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({ id: "Back" })
|
||||
: intl.formatMessage({ id: "Cancel" }),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./linkedReservation.module.css"
|
||||
|
||||
export default function LinkedReservationSkeleton() {
|
||||
return (
|
||||
<div className={styles.linkedReservation}>
|
||||
<div className={styles.title}>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "100px", height: "24px" }}
|
||||
>
|
||||
<SkeletonShimmer width="100px" height="24px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "200px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="200px" height="18px" />
|
||||
</div>
|
||||
<div className={styles.guests}>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "150px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="150px" height="18px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dates}>
|
||||
<div className={styles.date}>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "80px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="80px" height="18px" />
|
||||
</div>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "160px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="160px" height="18px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.date}>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "80px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="80px" height="18px" />
|
||||
</div>
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
style={{ width: "160px", height: "18px" }}
|
||||
>
|
||||
<SkeletonShimmer width="160px" height="18px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client"
|
||||
import { use, useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
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"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface LinkedReservationProps {
|
||||
bookingPromise: Promise<BookingConfirmation | null>
|
||||
index: number
|
||||
}
|
||||
|
||||
export default function LinkedReservation({
|
||||
bookingPromise,
|
||||
index,
|
||||
}: LinkedReservationProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const {
|
||||
actions: { setRoomPrice },
|
||||
} = useMyStayTotalPriceStore()
|
||||
const {
|
||||
actions: { setRoomDetails },
|
||||
} = useMyStayRoomDetailsStore()
|
||||
|
||||
const bookingConfirmation = use(bookingPromise)
|
||||
const { booking, room } = bookingConfirmation ?? {}
|
||||
|
||||
useEffect(() => {
|
||||
if (booking) {
|
||||
const childrenAsString = formatChildBedPreferences({
|
||||
childrenAges: booking.childrenAges ?? [],
|
||||
childBedPreferences: booking.childBedPreferences ?? [],
|
||||
})
|
||||
|
||||
setRoomPrice({
|
||||
id: booking.confirmationNumber,
|
||||
totalPrice: booking.totalPrice,
|
||||
currencyCode: booking.currencyCode,
|
||||
isMainBooking: false,
|
||||
})
|
||||
|
||||
// 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, setRoomPrice, setRoomDetails])
|
||||
|
||||
if (!booking) return null
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{adults, plural, one {# adult} other {# adults}}" },
|
||||
{
|
||||
adults: booking.adults,
|
||||
}
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: booking.childrenAges.length,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsOnlyMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
|
||||
return (
|
||||
<article className={styles.linkedReservation}>
|
||||
<div className={styles.title}>
|
||||
<Subtitle color="burgundy">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{
|
||||
roomIndex: index + 2,
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<Caption textTransform="uppercase" type="bold">
|
||||
{intl.formatMessage({ id: "Reference" })} {booking.confirmationNumber}
|
||||
</Caption>
|
||||
<div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{booking.childrenAges.length > 0
|
||||
? adultsAndChildrenMsg
|
||||
: adultsOnlyMsg}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dates}>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-in" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{`${fromDate.format("dddd, D MMMM")} `}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-out" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{`${toDate.format("dddd, D MMMM")} `}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
.linkedReservation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x2);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
border-right: 1px solid var(--Base-Border-Normal);
|
||||
width: 40%;
|
||||
align-content: center;
|
||||
}
|
||||
.details {
|
||||
display: flex;
|
||||
padding: 0 var(--Spacing-x1);
|
||||
gap: var(--Spacing-x2);
|
||||
flex-direction: column;
|
||||
}
|
||||
.dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import PriceContainer from "../../../PriceContainer"
|
||||
import { useCheckedRoomsCounts } from "../utils"
|
||||
|
||||
import type { PriceContainerProps } from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
import type {
|
||||
CancelStayFormValues,
|
||||
PriceContainerProps,
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
export default function CancelStayPriceContainer({
|
||||
booking,
|
||||
roomDetails,
|
||||
stayDetails,
|
||||
}: PriceContainerProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const checkedRoomsDetails = useCheckedRoomsCounts(booking, intl)
|
||||
const { getValues } = useFormContext<CancelStayFormValues>()
|
||||
const formRooms = getValues("rooms")
|
||||
|
||||
const checkedRoomsDetails = useCheckedRoomsCounts(
|
||||
roomDetails,
|
||||
formRooms,
|
||||
intl
|
||||
)
|
||||
|
||||
return (
|
||||
<PriceContainer
|
||||
text={intl.formatMessage({ id: "Cancellation cost" })}
|
||||
price={0}
|
||||
currencyCode={booking.currencyCode}
|
||||
currencyCode={roomDetails.currencyCode}
|
||||
nightsText={stayDetails.nightsText}
|
||||
adultsText={checkedRoomsDetails.adultsText}
|
||||
childrenText={checkedRoomsDetails.childrenText}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useMyStayRoomDetailsStore } from "@/components/HotelReservation/MyStay/stores/myStayRoomDetailsStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -19,12 +20,16 @@ import type {
|
||||
|
||||
export function CancelStayConfirmation({
|
||||
hotel,
|
||||
booking,
|
||||
stayDetails,
|
||||
}: CancelStayConfirmationProps) {
|
||||
const intl = useIntl()
|
||||
const { getValues } = useFormContext<CancelStayFormValues>()
|
||||
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
|
||||
const { watch } = useFormContext<CancelStayFormValues>()
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const { multiRoom } = bookedRoom
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,25 +50,31 @@ export function CancelStayConfirmation({
|
||||
{intl.formatMessage({ id: "No charges were made." })}
|
||||
</Caption>
|
||||
</div>
|
||||
{booking.multiRoom && (
|
||||
{multiRoom && (
|
||||
<>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Select rooms" })}
|
||||
</Body>
|
||||
|
||||
<div className={styles.rooms}>
|
||||
{getValues("rooms").map((room, index) => {
|
||||
{watch("rooms").map((room, index) => {
|
||||
// Find room details from store by confirmationNumber
|
||||
const roomDetail = roomDetails.find(
|
||||
(detail) => detail.id === room.confirmationNumber
|
||||
)
|
||||
const roomDetail =
|
||||
linkedReservationRooms.find(
|
||||
(detail) =>
|
||||
detail.confirmationNumber === room.confirmationNumber
|
||||
) ?? bookedRoom
|
||||
|
||||
return (
|
||||
<div key={room.id} className={styles.roomContainer}>
|
||||
<div
|
||||
key={room.confirmationNumber}
|
||||
className={styles.roomContainer}
|
||||
>
|
||||
<Checkbox
|
||||
name={`rooms.${index}.checked`}
|
||||
registerOptions={{
|
||||
disabled: !roomDetail?.isCancelable,
|
||||
disabled:
|
||||
!roomDetail.isCancelable || roomDetail.isCancelled,
|
||||
}}
|
||||
>
|
||||
<div className={styles.roomInfo}>
|
||||
@@ -90,8 +101,11 @@ export function CancelStayConfirmation({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{getValues("rooms").some((room) => room.checked) && (
|
||||
<CancelStayPriceContainer booking={booking} stayDetails={stayDetails} />
|
||||
{watch("rooms").some((room) => room.checked) && (
|
||||
<CancelStayPriceContainer
|
||||
roomDetails={bookedRoom}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import CancelStayPriceContainer from "../CancelStayPriceContainer"
|
||||
@@ -8,12 +10,11 @@ import styles from "../cancelStay.module.css"
|
||||
|
||||
import type { FinalConfirmationProps } from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
export function FinalConfirmation({
|
||||
booking,
|
||||
stayDetails,
|
||||
}: FinalConfirmationProps) {
|
||||
export function FinalConfirmation({ stayDetails }: FinalConfirmationProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalText}>
|
||||
@@ -23,7 +24,12 @@ export function FinalConfirmation({
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<CancelStayPriceContainer booking={booking} stayDetails={stayDetails} />
|
||||
{bookedRoom && (
|
||||
<CancelStayPriceContainer
|
||||
roomDetails={bookedRoom}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import {
|
||||
type Room,
|
||||
useMyStayRoomDetailsStore,
|
||||
} from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { useManageStayStore } from "@/components/HotelReservation/MyStay/stores/manageStayStore"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackCancelStay } from "@/utils/tracking"
|
||||
@@ -13,14 +17,12 @@ import type {
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
interface UseCancelStayProps extends Omit<CancelStayProps, "hotel"> {
|
||||
getFormValues: () => CancelStayFormValues
|
||||
checkedRooms: CancelStayFormValues["rooms"]
|
||||
}
|
||||
|
||||
export default function useCancelStay({
|
||||
booking,
|
||||
setBookingStatus,
|
||||
handleCloseModal,
|
||||
getFormValues,
|
||||
checkedRooms,
|
||||
}: UseCancelStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -28,12 +30,25 @@ export default function useCancelStay({
|
||||
actions: { setIsLoading },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const updateBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateBookedRoom
|
||||
)
|
||||
|
||||
const updateLinkedReservationRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateLinkedReservationRoom
|
||||
)
|
||||
|
||||
const cancelStay = trpc.booking.cancel.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
})
|
||||
|
||||
async function handleCancelStay() {
|
||||
if (!booking.confirmationNumber) {
|
||||
if (!bookedRoom.confirmationNumber) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong. Please try again later.",
|
||||
@@ -45,61 +60,96 @@ export default function useCancelStay({
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const formValues = getFormValues()
|
||||
const { rooms } = formValues
|
||||
const checkedRooms = rooms.filter((room) => room.checked)
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (const room of checkedRooms) {
|
||||
const confirmationNumber =
|
||||
room.confirmationNumber || booking.confirmationNumber
|
||||
let targetRoom: Room | undefined
|
||||
|
||||
// Check if this is the main booked room
|
||||
if (room.confirmationNumber === bookedRoom.confirmationNumber) {
|
||||
targetRoom = bookedRoom
|
||||
}
|
||||
// Check if this is a linked reservation room
|
||||
else {
|
||||
targetRoom = linkedReservationRooms.find(
|
||||
(r) => r.confirmationNumber === room.confirmationNumber
|
||||
)
|
||||
}
|
||||
|
||||
if (!targetRoom?.confirmationNumber) {
|
||||
errors.push(room.confirmationNumber)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await cancelStay.mutateAsync({
|
||||
confirmationNumber: confirmationNumber,
|
||||
const response = await cancelStay.mutateAsync({
|
||||
confirmationNumber: targetRoom.confirmationNumber,
|
||||
language: lang,
|
||||
})
|
||||
|
||||
if (result) {
|
||||
results.push(room.id)
|
||||
if (response) {
|
||||
results.push(room.confirmationNumber)
|
||||
const cancelledRoom = response.rooms.find(
|
||||
(r) => r.confirmationNumber === targetRoom?.confirmationNumber
|
||||
)
|
||||
|
||||
if (cancelledRoom) {
|
||||
if (
|
||||
targetRoom.confirmationNumber === bookedRoom.confirmationNumber
|
||||
) {
|
||||
// Update main booked room
|
||||
updateBookedRoom({
|
||||
...bookedRoom,
|
||||
isCancelled: true,
|
||||
cancellationNumber: cancelledRoom.cancellationNumber,
|
||||
})
|
||||
} else {
|
||||
// Update linked reservation room
|
||||
updateLinkedReservationRoom({
|
||||
...targetRoom,
|
||||
isCancelled: true,
|
||||
cancellationNumber: cancelledRoom.cancellationNumber,
|
||||
})
|
||||
}
|
||||
|
||||
trackCancelStay(
|
||||
bookedRoom.hotelId,
|
||||
cancelledRoom.confirmationNumber
|
||||
)
|
||||
}
|
||||
} else {
|
||||
errors.push(room.id)
|
||||
errors.push(room.confirmationNumber)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error cancelling room ${room.confirmationNumber}:`,
|
||||
`Error cancelling room ${targetRoom.confirmationNumber}:`,
|
||||
error
|
||||
)
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
errors.push(room.id)
|
||||
errors.push(room.confirmationNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate toast based on results
|
||||
if (results.length > 0 && errors.length === 0) {
|
||||
setBookingStatus()
|
||||
// All selected rooms cancelled successfully
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out",
|
||||
},
|
||||
{ currency: booking.currencyCode }
|
||||
{ currency: bookedRoom.currencyCode }
|
||||
)
|
||||
)
|
||||
trackCancelStay(booking.hotelId, booking.confirmationNumber)
|
||||
} else if (results.length > 0 && errors.length > 0) {
|
||||
setBookingStatus()
|
||||
// Some rooms cancelled, some failed
|
||||
toast.warning(
|
||||
intl.formatMessage({
|
||||
id: "Some rooms were cancelled successfully, but we encountered issues with others. Please contact customer service for assistance.",
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// No rooms cancelled successfully
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "Something went wrong. Please try again later.",
|
||||
@@ -115,6 +165,7 @@ export default function useCancelStay({
|
||||
id: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ 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 { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -21,44 +23,38 @@ import {
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface CancelStayProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
setBookingStatus: () => void
|
||||
}
|
||||
|
||||
export default function CancelStay({
|
||||
booking,
|
||||
hotel,
|
||||
setBookingStatus,
|
||||
}: CancelStayProps) {
|
||||
export default function CancelStay({ hotel }: CancelStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const form = useForm<CancelStayFormValues>({
|
||||
resolver: zodResolver(cancelStaySchema),
|
||||
defaultValues: {
|
||||
rooms: getDefaultRooms(booking),
|
||||
rooms: getDefaultRooms(bookedRoom),
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isLoading,
|
||||
actions: { handleForward, handleCloseView, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const { rooms } = form.watch()
|
||||
|
||||
const { handleCancelStay } = useCancelStay({
|
||||
booking,
|
||||
setBookingStatus,
|
||||
handleCloseModal,
|
||||
getFormValues: form.getValues,
|
||||
checkedRooms: rooms.filter((room) => room.checked),
|
||||
})
|
||||
|
||||
const { mainRoom } = booking
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
const stayDetails = formatStayDetails({ booking, lang, intl })
|
||||
const stayDetails = formatStayDetails({ bookedRoom, lang, intl })
|
||||
|
||||
function getModalCopy() {
|
||||
if (isFirstStep) {
|
||||
@@ -77,19 +73,13 @@ export default function CancelStay({
|
||||
}
|
||||
|
||||
function getModalContent() {
|
||||
if (mainRoom && isFirstStep)
|
||||
return (
|
||||
<CancelStayConfirmation
|
||||
hotel={hotel}
|
||||
booking={booking}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)
|
||||
if (bookedRoom && isFirstStep)
|
||||
return <CancelStayConfirmation hotel={hotel} stayDetails={stayDetails} />
|
||||
|
||||
if (mainRoom && !isFirstStep)
|
||||
return <FinalConfirmation booking={booking} stayDetails={stayDetails} />
|
||||
if (bookedRoom && !isFirstStep)
|
||||
return <FinalConfirmation stayDetails={stayDetails} />
|
||||
|
||||
if (!mainRoom && isFirstStep)
|
||||
if (!bookedRoom && isFirstStep)
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
@@ -103,7 +93,6 @@ export default function CancelStay({
|
||||
)
|
||||
}
|
||||
|
||||
const { rooms } = form.watch()
|
||||
const isFormValid = rooms?.some((room) => room.checked)
|
||||
|
||||
return (
|
||||
@@ -113,7 +102,7 @@ export default function CancelStay({
|
||||
content={getModalContent()}
|
||||
onClose={handleCloseModal}
|
||||
primaryAction={
|
||||
mainRoom
|
||||
bookedRoom
|
||||
? {
|
||||
label: getModalCopy().primaryLabel,
|
||||
onClick: isFirstStep ? handleForward : handleCancelStay,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
import type { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
export function getDefaultRooms(booking: BookingConfirmation["booking"]) {
|
||||
const { multiRoom, confirmationNumber, linkedReservations = [] } = booking
|
||||
export function getDefaultRooms(room: Room) {
|
||||
const { multiRoom, confirmationNumber, linkedReservations = [] } = room
|
||||
|
||||
if (!multiRoom) {
|
||||
return [{ id: "1", checked: true, confirmationNumber }]
|
||||
@@ -25,35 +23,43 @@ export function getDefaultRooms(booking: BookingConfirmation["booking"]) {
|
||||
}
|
||||
|
||||
export function formatStayDetails({
|
||||
booking,
|
||||
bookedRoom,
|
||||
lang,
|
||||
intl,
|
||||
}: {
|
||||
booking: BookingConfirmation["booking"]
|
||||
bookedRoom: Room
|
||||
lang: string
|
||||
intl: IntlShape
|
||||
}) {
|
||||
const { multiRoom } = booking
|
||||
const totalAdults = multiRoom
|
||||
? (booking.adults ?? 0) +
|
||||
(booking.linkedReservations ?? []).reduce((acc, reservation) => {
|
||||
return acc + (reservation.adults ?? 0)
|
||||
}, 0)
|
||||
: (booking.adults ?? 0)
|
||||
const totalChildren = multiRoom
|
||||
? booking.childrenAges?.length +
|
||||
(booking.linkedReservations ?? []).reduce((acc, reservation) => {
|
||||
return acc + reservation.children
|
||||
}, 0)
|
||||
: booking.childrenAges?.length
|
||||
const {
|
||||
multiRoom,
|
||||
adults,
|
||||
childrenAges,
|
||||
linkedReservations,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
} = bookedRoom
|
||||
|
||||
const checkInDate = dt(booking.checkInDate)
|
||||
const totalAdults = multiRoom
|
||||
? linkedReservations.reduce((acc, reservation) => {
|
||||
return acc + reservation.adults
|
||||
}, adults)
|
||||
: adults
|
||||
const totalChildren = multiRoom
|
||||
? linkedReservations.reduce((acc, reservation) => {
|
||||
return acc + reservation.children
|
||||
}, childrenAges.length)
|
||||
: childrenAges.length
|
||||
|
||||
const checkInDateFormatted = dt(checkInDate)
|
||||
.locale(lang)
|
||||
.format("dddd D MMM YYYY")
|
||||
const checkOutDate = dt(booking.checkOutDate)
|
||||
const checkOutDateFormatted = dt(checkOutDate)
|
||||
.locale(lang)
|
||||
.format("dddd D MMM YYYY")
|
||||
const diff = dt(checkOutDate).diff(checkInDate, "days")
|
||||
const diff = dt(checkOutDate)
|
||||
.startOf("day")
|
||||
.diff(dt(checkInDate).startOf("day"), "days")
|
||||
|
||||
const nightsText = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
@@ -69,8 +75,8 @@ export function formatStayDetails({
|
||||
)
|
||||
|
||||
return {
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
checkInDate: checkInDateFormatted,
|
||||
checkOutDate: checkOutDateFormatted,
|
||||
nightsText,
|
||||
adultsText,
|
||||
childrenText,
|
||||
@@ -79,31 +85,28 @@ export function formatStayDetails({
|
||||
}
|
||||
|
||||
function getMatchedRooms(
|
||||
booking: BookingConfirmation["booking"],
|
||||
roomDetails: Room,
|
||||
checkedConfirmationNumbers: string[]
|
||||
) {
|
||||
let matchedRooms = []
|
||||
|
||||
// Main booking
|
||||
if (checkedConfirmationNumbers.includes(booking.confirmationNumber)) {
|
||||
if (checkedConfirmationNumbers.includes(roomDetails.confirmationNumber)) {
|
||||
matchedRooms.push({
|
||||
adults: booking.adults ?? 0,
|
||||
children: booking.childrenAges?.length ?? 0,
|
||||
adults: roomDetails.adults,
|
||||
children: roomDetails.childrenAges.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Linked reservations
|
||||
if (booking.linkedReservations) {
|
||||
const matchedLinkedRooms = booking.linkedReservations
|
||||
.filter((reservation) =>
|
||||
checkedConfirmationNumbers.includes(reservation.confirmationNumber)
|
||||
)
|
||||
.map((reservation) => ({
|
||||
adults: reservation.adults ?? 0,
|
||||
children: reservation.children ?? 0,
|
||||
}))
|
||||
|
||||
matchedRooms = [...matchedRooms, ...matchedLinkedRooms]
|
||||
if (roomDetails.linkedReservations) {
|
||||
roomDetails.linkedReservations.forEach((reservation) => {
|
||||
if (checkedConfirmationNumbers.includes(reservation.confirmationNumber))
|
||||
matchedRooms.push({
|
||||
adults: reservation.adults,
|
||||
children: reservation.children,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return matchedRooms
|
||||
@@ -119,12 +122,10 @@ function calculateTotals(matchedRooms: { adults: number; children: number }[]) {
|
||||
}
|
||||
|
||||
export const useCheckedRoomsCounts = (
|
||||
booking: BookingConfirmation["booking"],
|
||||
roomDetails: Room,
|
||||
formRooms: CancelStayFormValues["rooms"],
|
||||
intl: IntlShape
|
||||
) => {
|
||||
const { getValues } = useFormContext<CancelStayFormValues>()
|
||||
const formRooms = getValues("rooms")
|
||||
|
||||
const checkedFormRooms = formRooms.filter((room) => room.checked)
|
||||
const checkedConfirmationNumbers = checkedFormRooms
|
||||
.map((room) => room.confirmationNumber)
|
||||
@@ -133,7 +134,7 @@ export const useCheckedRoomsCounts = (
|
||||
confirmationNumber !== null && confirmationNumber !== undefined
|
||||
)
|
||||
|
||||
const matchedRooms = getMatchedRooms(booking, checkedConfirmationNumbers)
|
||||
const matchedRooms = getMatchedRooms(roomDetails, checkedConfirmationNumbers)
|
||||
const { totalAdults, totalChildren } = calculateTotals(matchedRooms)
|
||||
|
||||
const adultsText = intl.formatMessage(
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
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"
|
||||
@@ -21,7 +21,7 @@ interface ConfirmationProps {
|
||||
nightsText: string
|
||||
adultsText: string
|
||||
childrenText: string
|
||||
totalChildren: number
|
||||
totalChildren?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ export default function Confirmation({
|
||||
.locale(lang)
|
||||
.format("dddd, DD MMM, YYYY")
|
||||
|
||||
const diff = dt(newCheckOut)
|
||||
.startOf("day")
|
||||
.diff(dt(newCheckIn).startOf("day"), "days")
|
||||
|
||||
const nightsText = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.dateComparison}>
|
||||
@@ -130,7 +139,7 @@ export default function Confirmation({
|
||||
text={intl.formatMessage({ id: "To be paid" })}
|
||||
price={newPrice}
|
||||
currencyCode={currencyCode}
|
||||
nightsText={stayDetails.nightsText}
|
||||
nightsText={nightsText}
|
||||
adultsText={stayDetails.adultsText}
|
||||
childrenText={stayDetails.childrenText}
|
||||
totalChildren={stayDetails.totalChildren}
|
||||
|
||||
@@ -21,7 +21,7 @@ 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"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
const locales = {
|
||||
[Lang.da]: da,
|
||||
@@ -32,7 +32,7 @@ const locales = {
|
||||
}
|
||||
|
||||
interface NewDatesProps {
|
||||
mainRoom: RoomDetails
|
||||
mainRoom: Room
|
||||
noAvailability: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
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"
|
||||
|
||||
@@ -13,18 +13,12 @@ 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,
|
||||
@@ -34,45 +28,51 @@ export default function useModifyStay({
|
||||
const {
|
||||
actions: { setIsLoading },
|
||||
} = useManageStayStore()
|
||||
const {
|
||||
rooms,
|
||||
actions: { updateRoomDetails },
|
||||
} = useMyStayRoomDetailsStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const updateBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateBookedRoom
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const updateBooking = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (updatedBooking) => {
|
||||
if (!updatedBooking) return
|
||||
if (!updatedBooking) {
|
||||
toast.error(intl.formatMessage({ id: "Failed to update your stay" }))
|
||||
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)
|
||||
const originalCheckIn = dt(bookedRoom.checkInDate)
|
||||
const originalCheckOut = dt(bookedRoom.checkOutDate)
|
||||
|
||||
updateBookedRoom({
|
||||
...bookedRoom,
|
||||
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(),
|
||||
})
|
||||
|
||||
toast.success(intl.formatMessage({ id: "Your stay was updated" }))
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoading(false)
|
||||
toast.error(intl.formatMessage({ id: "Failed to update your stay" }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function checkAvailability() {
|
||||
@@ -89,34 +89,32 @@ export default function useModifyStay({
|
||||
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,
|
||||
})
|
||||
try {
|
||||
const data = await utils.client.hotel.availability.room.query({
|
||||
hotelId: bookedRoom.hotelId,
|
||||
roomStayStartDate: formValues.checkInDate,
|
||||
roomStayEndDate: formValues.checkOutDate,
|
||||
adults: bookedRoom.adults,
|
||||
children: bookedRoom.childrenAsString,
|
||||
bookingCode: bookedRoom.bookingCode ?? undefined,
|
||||
rateCode: bookedRoom.rateDefinition.rateCode,
|
||||
roomTypeCode: bookedRoom.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 }
|
||||
if (!data?.selectedRoom || data.selectedRoom.roomsLeft <= 0) {
|
||||
return { success: false, noAvailability: true }
|
||||
}
|
||||
|
||||
const roomPrice = isLoggedIn
|
||||
? data.memberRate?.localPrice.pricePerStay
|
||||
: data.publicRate?.localPrice.pricePerStay
|
||||
|
||||
totalNewPrice += roomPrice ?? 0
|
||||
availabilityResults.push(data)
|
||||
} catch (error) {
|
||||
console.error("Error checking room availability:", error)
|
||||
return { success: false, error: true }
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -133,21 +131,12 @@ export default function useModifyStay({
|
||||
}
|
||||
|
||||
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,
|
||||
confirmationNumber: bookedRoom.confirmationNumber,
|
||||
checkInDate: formValues.checkInDate,
|
||||
checkOutDate: formValues.checkOutDate,
|
||||
})
|
||||
|
||||
@@ -5,9 +5,9 @@ import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
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"
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
export default function ModifyStay({ isLoggedIn }: ModifyStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
@@ -46,20 +46,24 @@ export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
isLoading,
|
||||
actions: { handleCloseView, handleCloseModal, setCurrentStep },
|
||||
} = useManageStayStore()
|
||||
const { rooms } = useMyStayRoomDetailsStore()
|
||||
|
||||
const { mainRoom: isMainRoom } = booking
|
||||
const stayDetails = formatStayDetails({ booking, lang, intl })
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const stayDetails = formatStayDetails({ bookedRoom, lang, intl })
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const mainRoom = rooms.find((room) => room.mainRoom)
|
||||
|
||||
const isMultiRoom = rooms.length > 1
|
||||
const {
|
||||
multiRoom,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
mainRoom,
|
||||
roomPrice,
|
||||
canChangeDate,
|
||||
} = bookedRoom
|
||||
|
||||
const { checkAvailability, handleModifyStay } = useModifyStay({
|
||||
booking,
|
||||
isLoggedIn: !!user,
|
||||
isLoggedIn,
|
||||
getFormValues: form.getValues,
|
||||
handleCloseModal,
|
||||
})
|
||||
@@ -84,20 +88,12 @@ export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
}
|
||||
|
||||
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])
|
||||
form.setValue("checkInDate", dt(checkInDate).format("YYYY-MM-DD"))
|
||||
form.setValue("checkOutDate", dt(checkOutDate).format("YYYY-MM-DD"))
|
||||
}, [checkInDate, checkOutDate, form])
|
||||
|
||||
function getModalContent() {
|
||||
if (mainRoom && isFirstStep && isMultiRoom) {
|
||||
if (bookedRoom && isFirstStep && multiRoom) {
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
@@ -110,10 +106,23 @@ export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mainRoom && !canChangeDate) {
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
id: "Contact customer service",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
id: "Please contact customer service to update the dates.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mainRoom && isFirstStep)
|
||||
return (
|
||||
<NewDates
|
||||
mainRoom={mainRoom}
|
||||
mainRoom={bookedRoom}
|
||||
noAvailability={noAvailability}
|
||||
error={error}
|
||||
/>
|
||||
@@ -122,7 +131,7 @@ export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
if (mainRoom && !isFirstStep)
|
||||
return (
|
||||
<Confirmation
|
||||
oldPrice={booking.roomPrice}
|
||||
oldPrice={roomPrice.perStay.local.price}
|
||||
newPrice={newRoomPrice}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
@@ -153,7 +162,7 @@ export default function ModifyStay({ booking, user }: ModifyStayProps) {
|
||||
content={getModalContent()}
|
||||
onClose={handleCloseModal}
|
||||
primaryAction={
|
||||
isMainRoom && !isMultiRoom
|
||||
mainRoom && !multiRoom && canChangeDate
|
||||
? {
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({ id: "Check availability" })
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PriceContainerProps {
|
||||
nightsText: string
|
||||
adultsText: string
|
||||
childrenText: string
|
||||
totalChildren: number
|
||||
totalChildren?: number
|
||||
}
|
||||
|
||||
export default function PriceContainer({
|
||||
@@ -20,7 +20,7 @@ export default function PriceContainer({
|
||||
nightsText,
|
||||
adultsText,
|
||||
childrenText,
|
||||
totalChildren,
|
||||
totalChildren = 0,
|
||||
}: PriceContainerProps) {
|
||||
return (
|
||||
<div className={styles.priceContainer}>
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.actionPanel {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -19,12 +13,6 @@
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.menu {
|
||||
width: 432px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionPanel .menu .button,
|
||||
.actionLink {
|
||||
width: 100%;
|
||||
@@ -49,12 +37,6 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.info {
|
||||
width: 256px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
@@ -66,3 +48,17 @@
|
||||
.link {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.actionPanel {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 432px;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 256px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { customerService } from "@/constants/currentWebHrefs"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import { preliminaryReceipt } from "@/constants/routes/myStay"
|
||||
|
||||
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
|
||||
@@ -23,7 +24,6 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackMyStayPageLink } from "@/utils/tracking"
|
||||
|
||||
import { useManageStayStore } from "../../stores/manageStayStore"
|
||||
import AddToCalendarButton from "./Actions/AddToCalendarButton"
|
||||
|
||||
import styles from "./actionPanel.module.css"
|
||||
@@ -31,46 +31,45 @@ import styles from "./actionPanel.module.css"
|
||||
import type { EventAttributes } from "ics"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface ActionPanelProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
bookingStatus: string | null
|
||||
showGuaranteeButton: boolean
|
||||
onCancelClick: () => void
|
||||
onGuaranteeClick: () => void
|
||||
}
|
||||
|
||||
export default function ActionPanel({
|
||||
booking,
|
||||
hotel,
|
||||
bookingStatus,
|
||||
showGuaranteeButton,
|
||||
onGuaranteeClick,
|
||||
}: ActionPanelProps) {
|
||||
export default function ActionPanel({ hotel }: ActionPanelProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
actions: { setActiveView },
|
||||
} = useManageStayStore()
|
||||
|
||||
const showCancelStayButton =
|
||||
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const event: EventAttributes = {
|
||||
const showCancelStayButton =
|
||||
bookedRoom.isCancelable ||
|
||||
linkedReservationRooms.some((room) => room.isCancelable)
|
||||
const showGuaranteeButton =
|
||||
!bookedRoom.guaranteeInfo && !bookedRoom.isCancelled
|
||||
|
||||
const { confirmationNumber, checkInDate, checkOutDate, createDateTime } =
|
||||
bookedRoom
|
||||
|
||||
const calendarEvent: EventAttributes = {
|
||||
busyStatus: "FREE",
|
||||
categories: ["booking", "hotel", "stay"],
|
||||
created: generateDateTime(booking.createDateTime),
|
||||
created: generateDateTime(createDateTime),
|
||||
description: hotel.hotelContent.texts.descriptions?.medium,
|
||||
end: generateDateTime(booking.checkOutDate),
|
||||
end: generateDateTime(checkOutDate),
|
||||
endInputType: "utc",
|
||||
geo: {
|
||||
lat: hotel.location.latitude,
|
||||
lon: hotel.location.longitude,
|
||||
},
|
||||
location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`,
|
||||
start: generateDateTime(booking.checkInDate),
|
||||
start: generateDateTime(checkInDate),
|
||||
startInputType: "utc",
|
||||
status: "CONFIRMED",
|
||||
title: hotel.name,
|
||||
@@ -93,7 +92,7 @@ export default function ActionPanel({
|
||||
|
||||
const handleGuaranteeLateArrival = () => {
|
||||
trackMyStayPageLink("guarantee late arrival")
|
||||
onGuaranteeClick()
|
||||
setActiveView("guaranteeLateArrival")
|
||||
}
|
||||
|
||||
const handleCustomerSupport = () => {
|
||||
@@ -124,8 +123,8 @@ export default function ActionPanel({
|
||||
</Button>
|
||||
)}
|
||||
<AddToCalendar
|
||||
checkInDate={booking.checkInDate}
|
||||
event={event}
|
||||
checkInDate={checkInDate}
|
||||
event={calendarEvent}
|
||||
hotelName={hotel.name}
|
||||
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
|
||||
/>
|
||||
@@ -157,7 +156,7 @@ export default function ActionPanel({
|
||||
{intl.formatMessage({ id: "Reference number" })}
|
||||
</span>
|
||||
<Subtitle color="burgundy" textAlign="right">
|
||||
{booking.confirmationNumber}
|
||||
{confirmationNumber}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,98 +2,90 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { ChevronDownIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
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, type User } from "@/types/user"
|
||||
import { type CreditCard } 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
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export default function ManageStay({
|
||||
booking,
|
||||
hotel,
|
||||
setBookingStatus,
|
||||
bookingStatus,
|
||||
user,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
isLoggedIn,
|
||||
}: ManageStayProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
isOpen,
|
||||
activeView,
|
||||
actions: { setIsOpen, handleCloseModal, setActiveView },
|
||||
actions: { setIsOpen, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const showGuaranteeButton =
|
||||
bookingStatus !== BookingStatusEnum.Cancelled && !booking.guaranteeInfo
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const allRoomsCancelled =
|
||||
linkedReservationRooms.every((room) => room.isCancelled) &&
|
||||
bookedRoom.isCancelled
|
||||
|
||||
function renderContent() {
|
||||
switch (activeView) {
|
||||
case "cancelStay":
|
||||
return (
|
||||
<CancelStay
|
||||
booking={booking}
|
||||
hotel={hotel}
|
||||
setBookingStatus={() =>
|
||||
setBookingStatus(BookingStatusEnum.Cancelled)
|
||||
}
|
||||
/>
|
||||
)
|
||||
return <CancelStay hotel={hotel} />
|
||||
case "modifyStay":
|
||||
return <ModifyStay booking={booking} user={user} />
|
||||
return <ModifyStay isLoggedIn={isLoggedIn} />
|
||||
case "guaranteeLateArrival":
|
||||
return (
|
||||
<GuaranteeLateArrival
|
||||
booking={booking}
|
||||
handleCloseModal={handleCloseModal}
|
||||
handleBackToManageStay={() => setActiveView("actionPanel")}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<ActionPanel
|
||||
booking={booking}
|
||||
hotel={hotel}
|
||||
bookingStatus={bookingStatus}
|
||||
onCancelClick={() => setActiveView("cancelStay")}
|
||||
onGuaranteeClick={() => setActiveView("guaranteeLateArrival")}
|
||||
showGuaranteeButton={showGuaranteeButton}
|
||||
/>
|
||||
)
|
||||
return <ActionPanel hotel={hotel} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="icon" fullWidth onClick={() => setIsOpen(true)}>
|
||||
<Button
|
||||
variant="icon"
|
||||
fullWidth
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="small"
|
||||
disabled={allRoomsCancelled}
|
||||
>
|
||||
{intl.formatMessage({ id: "Manage stay" })}
|
||||
<ChevronDownIcon width={24} height={24} color="burgundy" />
|
||||
</Button>
|
||||
<Modal isOpen={isOpen} onToggle={handleCloseModal} withActions hideHeader>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onToggle={handleCloseModal}
|
||||
withActions
|
||||
hideHeader
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
|
||||
import styles from "./multiRoom.module.css"
|
||||
|
||||
export default function MultiRoomSkeleton() {
|
||||
return (
|
||||
<div className={styles.multiRoom}>
|
||||
<div className={styles.roomName}>
|
||||
<SkeletonShimmer width="50%" height="30px" />
|
||||
</div>
|
||||
<div className={styles.roomHeader}>
|
||||
<SkeletonShimmer width="100%" height="23px" />
|
||||
</div>
|
||||
<div className={styles.multiRoomCard}>
|
||||
<div className={styles.imageContainer}>
|
||||
<SkeletonShimmer width="100%" height="100%" />
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<div className={styles.row}>
|
||||
<SkeletonShimmer width="60px" height="23px" />
|
||||
<SkeletonShimmer width="110px" height="23px" />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<SkeletonShimmer width="55px" height="23px" />
|
||||
<SkeletonShimmer width="130px" height="23px" />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<SkeletonShimmer width="75px" height="23px" />
|
||||
<SkeletonShimmer width="155px" height="23px" />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<SkeletonShimmer width="75px" height="23px" />
|
||||
<SkeletonShimmer width="65px" height="23px" />
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
<div className={styles.row}>
|
||||
<SkeletonShimmer width="150px" height="30px" />
|
||||
<SkeletonShimmer width="150px" height="30px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import { ExpandIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./toggleSidePeek.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
user,
|
||||
confirmationNumber,
|
||||
}: ToggleSidePeekProps) {
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.bookedRoomDetails,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
user,
|
||||
confirmationNumber,
|
||||
})
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
<div className={styles.iconContainer}>
|
||||
<ExpandIcon />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client"
|
||||
import { use, useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import CrossCircleIcon from "@/components/Icons/CrossCircle"
|
||||
import Image from "@/components/Image"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { getIconForFeatureCode } from "../../utils"
|
||||
import Price from "../Price"
|
||||
import { hasBreakfastPackage } from "../utils/hasBreakfastPackage"
|
||||
import { mapRoomDetails } from "../utils/mapRoomDetails"
|
||||
import MultiRoomSkeleton from "./MultiRoomSkeleton"
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./multiRoom.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface MultiRoomProps {
|
||||
booking?: BookingConfirmation["booking"]
|
||||
room?:
|
||||
| (Room & {
|
||||
bedType: Room["roomTypes"][number]
|
||||
})
|
||||
| null
|
||||
bookingPromise?: Promise<BookingConfirmation | null>
|
||||
index?: number
|
||||
user?: User | null
|
||||
}
|
||||
|
||||
export default function MultiRoom({
|
||||
room: initialRoom,
|
||||
booking: initialBooking,
|
||||
bookingPromise,
|
||||
index,
|
||||
user,
|
||||
}: MultiRoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const addRoomPrice = useMyStayTotalPriceStore(
|
||||
(state) => state.actions.addRoomPrice
|
||||
)
|
||||
|
||||
const addLinkedReservationRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.addLinkedReservationRoom
|
||||
)
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const allRooms = [bookedRoom, ...linkedReservationRooms]
|
||||
|
||||
// Resolve promise data directly without setState
|
||||
let bookingInfo = initialBooking
|
||||
let roomInfo = initialRoom
|
||||
|
||||
if (bookingPromise) {
|
||||
const promiseData = use(bookingPromise)
|
||||
if (promiseData) {
|
||||
bookingInfo = promiseData.booking
|
||||
roomInfo = promiseData.room
|
||||
}
|
||||
}
|
||||
const isBookingCancelled =
|
||||
bookingInfo?.reservationStatus === BookingStatusEnum.Cancelled
|
||||
|
||||
const multiRoom = allRooms.find(
|
||||
(room) => room.confirmationNumber === bookingInfo?.confirmationNumber
|
||||
)
|
||||
|
||||
// Update stores when data is available
|
||||
useEffect(() => {
|
||||
if (bookingInfo) {
|
||||
addRoomPrice({
|
||||
id: bookingInfo.confirmationNumber,
|
||||
totalPrice: isBookingCancelled ? 0 : bookingInfo.totalPrice,
|
||||
currencyCode: bookingInfo.currencyCode,
|
||||
isMainBooking: false,
|
||||
})
|
||||
|
||||
// Add room details to the store
|
||||
addLinkedReservationRoom(
|
||||
mapRoomDetails({
|
||||
booking: bookingInfo,
|
||||
room: roomInfo ?? null,
|
||||
roomNumber: index !== undefined ? index + 2 : 1,
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [
|
||||
bookingInfo,
|
||||
roomInfo,
|
||||
index,
|
||||
isBookingCancelled,
|
||||
addRoomPrice,
|
||||
addLinkedReservationRoom,
|
||||
])
|
||||
|
||||
if (!multiRoom?.roomNumber) return <MultiRoomSkeleton />
|
||||
|
||||
const {
|
||||
adults,
|
||||
checkInDate,
|
||||
childrenAges,
|
||||
confirmationNumber,
|
||||
cancellationNumber,
|
||||
hotelId,
|
||||
roomPrice,
|
||||
packages,
|
||||
rateDefinition,
|
||||
isCancelled,
|
||||
} = multiRoom
|
||||
|
||||
const fromDate = dt(checkInDate).locale(lang)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{adults, plural, one {# adult} other {# adults}}" },
|
||||
{
|
||||
adults: adults,
|
||||
}
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: childrenAges.length,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsOnlyMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
|
||||
return (
|
||||
<article className={styles.multiRoom}>
|
||||
<Typography variant="Title/smRegular">
|
||||
<h3 className={styles.roomName}>{roomInfo?.name}</h3>
|
||||
</Typography>
|
||||
<div className={styles.roomHeader}>
|
||||
{isCancelled ? (
|
||||
<IconChip
|
||||
color={"red"}
|
||||
icon={<CrossCircleIcon width={20} height={20} color="red" />}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{intl.formatMessage({ id: "Cancelled" })}</span>
|
||||
</Typography>
|
||||
</IconChip>
|
||||
) : (
|
||||
<div className={styles.chip}>
|
||||
<Typography variant="Tag/sm">
|
||||
<span>
|
||||
{intl.formatMessage({ id: "Room" }) +
|
||||
" " +
|
||||
(index !== undefined ? index + 2 : 1)}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.reference}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
{isCancelled ? (
|
||||
<span>{intl.formatMessage({ id: "Cancellation no" })}:</span>
|
||||
) : (
|
||||
<span>{intl.formatMessage({ id: "Reference" })}:</span>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
{isCancelled ? (
|
||||
<span className={styles.cancellationNumber}>
|
||||
{cancellationNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span>{confirmationNumber}</span>
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.toggleSidePeek}>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomInfo?.roomTypes[0].code}
|
||||
user={user ?? undefined}
|
||||
confirmationNumber={confirmationNumber}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.multiRoomCard} ${isCancelled ? styles.cancelled : ""}`}
|
||||
>
|
||||
{packages &&
|
||||
packages.some((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
) && (
|
||||
<div className={styles.packages}>
|
||||
{packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = getIconForFeatureCode(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
return (
|
||||
<span className={styles.package} key={item.code}>
|
||||
<Icon width={16} height={16} color="burgundy" />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
src={roomInfo?.images[0]?.imageSizes.small ?? ""}
|
||||
alt={roomInfo?.name ?? ""}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<div className={styles.row}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Guests" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{childrenAges.length > 0 ? adultsAndChildrenMsg : adultsOnlyMsg}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Terms" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{rateDefinition.cancellationText}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Modify By" })}</p>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
18:00, {fromDate.format("dddd D MMM")}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Breakfast" })}</p>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{hasBreakfastPackage(
|
||||
packages?.map((pkg) => ({
|
||||
code: pkg.code,
|
||||
})) ?? []
|
||||
)
|
||||
? intl.formatMessage({ id: "Included" })
|
||||
: intl.formatMessage({ id: "Not included" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Divider color="subtle" />
|
||||
<div className={styles.row}>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>{intl.formatMessage({ id: "Room total" })}</p>
|
||||
</Typography>
|
||||
<Price
|
||||
price={isCancelled ? 0 : roomPrice.perStay.local.price}
|
||||
variant="Body/Paragraph/mdBold"
|
||||
isMember={rateDefinition.isMemberRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.multiRoom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.cancelled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cancellationNumber {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.multiRoomCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
width: 100%;
|
||||
height: 342px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.roomName {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.roomHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Scandic-Peach-30);
|
||||
color: var(--Scandic-Red-100);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.toggleSidePeek {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reference {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) 0;
|
||||
gap: var(--Spacing-x2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.packages {
|
||||
position: absolute;
|
||||
top: 304px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.package {
|
||||
background-color: var(--Main-Grey-White);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.multiRoom {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./price.module.css"
|
||||
|
||||
export type Variant =
|
||||
| "Title/Subtitle/lg"
|
||||
| "Title/Subtitle/md"
|
||||
| "Body/Paragraph/mdBold"
|
||||
|
||||
export default function Price({
|
||||
price,
|
||||
variant,
|
||||
isMember,
|
||||
}: {
|
||||
price: number | null
|
||||
variant: Variant
|
||||
isMember?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const currencyCode = useMyStayTotalPriceStore((state) => state.currencyCode)
|
||||
|
||||
if (price === null) {
|
||||
return <SkeletonShimmer width={"100px"} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant={variant}>
|
||||
<p className={isMember ? styles.memberPrice : styles.nonMemberPrice}>
|
||||
{formatPrice(intl, price, currencyCode)}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.memberPrice {
|
||||
color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
.nonMemberPrice {
|
||||
color: var(--Main-Grey-100);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Fragment } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { PriceTagIcon } from "@/components/Icons"
|
||||
@@ -13,9 +15,8 @@ import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./priceDetailsTable.module.css"
|
||||
|
||||
import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
function Row({
|
||||
label,
|
||||
@@ -29,10 +30,26 @@ function Row({
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<Caption type={bold ? "bold" : undefined}>{label}</Caption>
|
||||
<Typography
|
||||
variant={
|
||||
bold
|
||||
? "Body/Supporting text (caption)/smBold"
|
||||
: "Body/Supporting text (caption)/smRegular"
|
||||
}
|
||||
>
|
||||
<span>{label}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
<Caption type={bold ? "bold" : undefined}>{value}</Caption>
|
||||
<Typography
|
||||
variant={
|
||||
bold
|
||||
? "Body/Supporting text (caption)/smBold"
|
||||
: "Body/Supporting text (caption)/smRegular"
|
||||
}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -50,26 +67,46 @@ function TableSectionHeader({
|
||||
subtitle?: string
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Body>{title}</Body>
|
||||
{subtitle ? <Body>{subtitle}</Body> : null}
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{title}</span>
|
||||
</Typography>
|
||||
</th>
|
||||
</tr>
|
||||
{subtitle && (
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{subtitle}</span>
|
||||
</Typography>
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface Room {
|
||||
adults: number
|
||||
childrenInRoom: Child[] | undefined
|
||||
roomPrice: RoomPrice
|
||||
roomType: string
|
||||
export type RoomPriceDetails = Pick<
|
||||
Room,
|
||||
| "adults"
|
||||
| "bedType"
|
||||
| "breakfast"
|
||||
| "childrenInRoom"
|
||||
| "roomPrice"
|
||||
| "roomName"
|
||||
| "packages"
|
||||
| "isCancelled"
|
||||
> & {
|
||||
guest?: Room["guest"]
|
||||
}
|
||||
|
||||
export interface PriceDetailsTableProps {
|
||||
bookingCode?: string | null
|
||||
fromDate: string
|
||||
rooms: Room[]
|
||||
bookedRoom: RoomPriceDetails
|
||||
linkedReservationRooms: RoomPriceDetails[]
|
||||
toDate: string
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
@@ -78,7 +115,8 @@ export interface PriceDetailsTableProps {
|
||||
export default function PriceDetailsTable({
|
||||
bookingCode,
|
||||
fromDate,
|
||||
rooms,
|
||||
bookedRoom,
|
||||
linkedReservationRooms,
|
||||
toDate,
|
||||
totalPrice,
|
||||
vat,
|
||||
@@ -86,6 +124,10 @@ export default function PriceDetailsTable({
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const rooms = [bookedRoom, ...linkedReservationRooms].filter(
|
||||
(room) => !room.isCancelled
|
||||
)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
@@ -99,6 +141,7 @@ export default function PriceDetailsTable({
|
||||
const duration = ` ${dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
-
|
||||
${dt(toDate).locale(lang).format("ddd, D MMM")} (${nights})`
|
||||
|
||||
return (
|
||||
<table className={styles.priceDetailsTable}>
|
||||
{rooms.map((room, idx) => {
|
||||
@@ -106,11 +149,20 @@ export default function PriceDetailsTable({
|
||||
<Fragment key={idx}>
|
||||
<TableSection>
|
||||
{rooms.length > 1 && (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Room" })} {idx + 1}
|
||||
</Body>
|
||||
<tr>
|
||||
<td>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: idx + 1 }
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<TableSectionHeader title={room.roomType} subtitle={duration} />
|
||||
<TableSectionHeader title={room.roomName} subtitle={duration} />
|
||||
<Row
|
||||
label={intl.formatMessage({ id: "Average price per night" })}
|
||||
value={formatPrice(
|
||||
@@ -119,6 +171,19 @@ export default function PriceDetailsTable({
|
||||
room.roomPrice.perNight.local.currency
|
||||
)}
|
||||
/>
|
||||
{room.packages
|
||||
? room.packages.map((feature) => (
|
||||
<Row
|
||||
key={feature.code}
|
||||
label={feature.description}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
+feature.localPrice.totalPrice,
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<Row
|
||||
bold
|
||||
label={intl.formatMessage({ id: "Room charge" })}
|
||||
@@ -129,6 +194,52 @@ export default function PriceDetailsTable({
|
||||
)}
|
||||
/>
|
||||
</TableSection>
|
||||
{room.breakfast ? (
|
||||
<TableSection>
|
||||
<Row
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
id: "Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
|
||||
},
|
||||
{ totalAdults: room.adults, totalBreakfasts: diff }
|
||||
)}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
room.breakfast.localPrice.price * room.adults,
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
{room.childrenInRoom?.length ? (
|
||||
<Row
|
||||
label={intl.formatMessage(
|
||||
{
|
||||
id: "Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
|
||||
},
|
||||
{
|
||||
totalChildren: room.childrenInRoom.length,
|
||||
totalBreakfasts: diff,
|
||||
}
|
||||
)}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Row
|
||||
bold
|
||||
label={intl.formatMessage({
|
||||
id: "Breakfast charge",
|
||||
})}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
room.breakfast.localPrice.price * room.adults * diff,
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
</TableSection>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
@@ -29,6 +29,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.priceDetailsTable {
|
||||
min-width: 512px;
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import PriceDetailsModal from "../../PriceDetailsModal"
|
||||
import PriceDetailsTable from "./PriceDetailsTable"
|
||||
|
||||
import styles from "./priceDetails.module.css"
|
||||
|
||||
export default function PriceDetails() {
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
const currencyCode = useMyStayTotalPriceStore((state) => state.currencyCode)
|
||||
const totalPrice = useMyStayTotalPriceStore((state) => state.totalPrice)
|
||||
|
||||
return (
|
||||
<div className={styles.priceDetailsModal}>
|
||||
<PriceDetailsModal>
|
||||
<PriceDetailsTable
|
||||
fromDate={dt(bookedRoom.checkInDate).format("YYYY-MM-DD")}
|
||||
toDate={dt(bookedRoom.checkOutDate).format("YYYY-MM-DD")}
|
||||
linkedReservationRooms={linkedReservationRooms}
|
||||
bookedRoom={bookedRoom}
|
||||
totalPrice={{
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: currencyCode,
|
||||
price: totalPrice ?? 0,
|
||||
},
|
||||
}}
|
||||
vat={bookedRoom.vatPercentage}
|
||||
/>
|
||||
</PriceDetailsModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.priceDetailsModal {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./promo.module.css"
|
||||
|
||||
import type { PromoProps } from "@/types/components/hotelReservation/bookingConfirmation/promo"
|
||||
|
||||
export default function Promo({ buttonText, href, text, title }: PromoProps) {
|
||||
export default function Promo({
|
||||
buttonText,
|
||||
href,
|
||||
text,
|
||||
title,
|
||||
image,
|
||||
}: PromoProps) {
|
||||
return (
|
||||
<Link className={styles.link} color="none" href={href}>
|
||||
<Link color="none" href={href}>
|
||||
<article className={styles.promo}>
|
||||
<Title color="white" level="h4">
|
||||
{title}
|
||||
</Title>
|
||||
<Body className={styles.text} color="white" textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
<Button asChild intent="primary" size="small" theme="primaryStrong">
|
||||
<div>{buttonText}</div>
|
||||
</Button>
|
||||
{image && (
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={image.imageSizes.large}
|
||||
alt={image.metaData.altText}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.overlay} />
|
||||
<div className={styles.content}>
|
||||
<Typography variant="Title/smRegular">
|
||||
<p>{title}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.text}>{text}</p>
|
||||
</Typography>
|
||||
<Button asChild intent="secondary" size="small" theme="primaryStrong">
|
||||
<span>{buttonText}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
.promo {
|
||||
align-items: center;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
height: 480px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 0 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 0 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
height: 480px;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x3);
|
||||
color: var(--UI-Opacity-White-100);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.promo {
|
||||
border-radius: var(--Medium, 8px);
|
||||
border-radius: var(--Corner-radius-xLarge);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 0 480px;
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.link .promo {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
),
|
||||
url("/_static/img/Scandic_Family_Breakfast.jpg");
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
|
||||
import styles from "./referenceCard.module.css"
|
||||
|
||||
export default function ReferenceCardSkeleton() {
|
||||
return (
|
||||
<div className={styles.referenceCard}>
|
||||
<div className={styles.referenceRow}>
|
||||
<SkeletonShimmer width="100%" height="28px" />
|
||||
</div>
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
<SkeletonShimmer width="20%" height="24px" />
|
||||
</div>
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<SkeletonShimmer width="100%" height="28px" />
|
||||
</div>
|
||||
<div className={styles.actionArea}>
|
||||
<SkeletonShimmer width="100%" height="44px" />
|
||||
<SkeletonShimmer width="100%" height="44px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import { BookingCodeIcon, CheckCircleIcon } 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"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useGuaranteePaymentFailedToast } from "@/hooks/booking/useGuaranteePaymentFailedToast"
|
||||
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 TotalPrice from "../Rooms/TotalPrice"
|
||||
import { mapRoomDetails } from "../utils/mapRoomDetails"
|
||||
import ReferenceCardSkeleton from "./ReferenceCardSkeleton"
|
||||
|
||||
import styles from "./referenceCard.module.css"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { Hotel, Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import { type CreditCard, type User } from "@/types/user"
|
||||
import type { CreditCard } from "@/types/user"
|
||||
|
||||
interface ReferenceCardProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
user: User | null
|
||||
room:
|
||||
| (Room & {
|
||||
bedType: Room["roomTypes"][number]
|
||||
})
|
||||
| null
|
||||
savedCreditCards: CreditCard[] | null
|
||||
refId: string
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export function ReferenceCard({
|
||||
booking,
|
||||
hotel,
|
||||
user,
|
||||
room,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
isLoggedIn,
|
||||
}: ReferenceCardProps) {
|
||||
const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { totalPrice, currencyCode } = useMyStayTotalPriceStore()
|
||||
const { rooms } = useMyStayRoomDetailsStore()
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
const addBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.addBookedRoom
|
||||
)
|
||||
const addRoomPrice = useMyStayTotalPriceStore(
|
||||
(state) => state.actions.addRoomPrice
|
||||
)
|
||||
|
||||
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)
|
||||
// Initialize store with server data
|
||||
useEffect(() => {
|
||||
// Add price and details for booked room (main room or single room)
|
||||
addRoomPrice({
|
||||
id: booking.confirmationNumber,
|
||||
totalPrice:
|
||||
booking.reservationStatus === BookingStatusEnum.Cancelled
|
||||
? 0
|
||||
: booking.totalPrice,
|
||||
currencyCode: booking.currencyCode,
|
||||
isMainBooking: true,
|
||||
})
|
||||
addBookedRoom(
|
||||
mapRoomDetails({
|
||||
booking,
|
||||
room,
|
||||
roomNumber: 1,
|
||||
})
|
||||
)
|
||||
}, [booking, room, addBookedRoom, addRoomPrice])
|
||||
|
||||
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
|
||||
useGuaranteePaymentFailedToast()
|
||||
|
||||
if (!bookedRoom.roomNumber) return <ReferenceCardSkeleton />
|
||||
|
||||
const {
|
||||
confirmationNumber,
|
||||
cancellationNumber,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
isCancelled,
|
||||
isModifiable,
|
||||
bookingCode,
|
||||
} = bookedRoom
|
||||
|
||||
const fromDate = dt(checkInDate).locale(lang)
|
||||
const toDate = dt(checkOutDate).locale(lang)
|
||||
|
||||
const isMultiRoom = bookedRoom.linkedReservations.length > 0
|
||||
|
||||
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
|
||||
|
||||
const adults =
|
||||
booking.adults +
|
||||
(booking.linkedReservations?.reduce(
|
||||
(acc, linkedReservation) => acc + linkedReservation.adults,
|
||||
0
|
||||
) ?? 0)
|
||||
const allRooms = [bookedRoom, ...linkedReservationRooms]
|
||||
|
||||
const children =
|
||||
booking.childrenAges.length +
|
||||
(booking.linkedReservations?.reduce(
|
||||
(acc, linkedReservation) => acc + linkedReservation.children,
|
||||
0
|
||||
) ?? 0)
|
||||
const adults = allRooms
|
||||
.filter((room) => !room.isCancelled)
|
||||
.reduce((acc, room) => acc + room.adults, 0)
|
||||
|
||||
const children = allRooms
|
||||
.filter((room) => !room.isCancelled)
|
||||
.reduce((acc, room) => acc + (room.childrenAges?.length ?? 0), 0)
|
||||
|
||||
const cancelledRooms = allRooms.filter((room) => room.isCancelled).length
|
||||
const allRoomsCancelled = allRooms.every((room) => room.isCancelled)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{adults, plural, one {# adult} other {# adults}}" },
|
||||
@@ -94,142 +135,186 @@ export function ReferenceCard({
|
||||
}
|
||||
)
|
||||
|
||||
const cancelledRoomsMsg = intl.formatMessage(
|
||||
{ id: "{rooms, plural, one {# room} other {# rooms}}" },
|
||||
{
|
||||
rooms: cancelledRooms,
|
||||
}
|
||||
)
|
||||
|
||||
const roomCancelledRoomsMsg = intl.formatMessage({ id: "Room cancelled" })
|
||||
|
||||
const roomsMsg = intl.formatMessage(
|
||||
{ id: "{rooms, plural, one {# room} other {# rooms}}" },
|
||||
{
|
||||
rooms: allRooms.filter((room) => !room.isCancelled).length,
|
||||
}
|
||||
)
|
||||
const adultsOnlyMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
const adultsAndRoomsMsg = [adultsMsg, roomsMsg].join(", ")
|
||||
const adultsAndChildrenAndRoomsMsg = [adultsMsg, childrenMsg, roomsMsg].join(
|
||||
", "
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.referenceCard}>
|
||||
<div className={styles.referenceRow}>
|
||||
<Subtitle color="uiTextHighContrast" className={styles.titleMobile}>
|
||||
{intl.formatMessage({ id: "Reference" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast" className={styles.titleDesktop}>
|
||||
{isCancelled
|
||||
? intl.formatMessage({ id: "Cancellation number" })
|
||||
: intl.formatMessage({ id: "Reference number" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{isCancelled
|
||||
? booking.cancellationNumber
|
||||
: booking.confirmationNumber}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Guests" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{children > 0 ? adultsAndChildrenMsg : adultsOnlyMsg}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-in" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{`${fromDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-out" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${toDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
{booking.guaranteeInfo && (
|
||||
<div className={styles.guaranteed}>
|
||||
<CheckCircleIcon color="green" height={20} width={20} />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.guaranteedText}>
|
||||
<strong>
|
||||
{intl.formatMessage({ id: "Booking guaranteed." })}
|
||||
</strong>{" "}
|
||||
{intl.formatMessage({
|
||||
id: "Your stay remains available for check-in after 18:00.",
|
||||
})}
|
||||
{!isMultiRoom && (
|
||||
<>
|
||||
<div className={styles.referenceRow}>
|
||||
<Subtitle color="uiTextHighContrast" className={styles.titleMobile}>
|
||||
{intl.formatMessage({ id: "Reference" })}
|
||||
</Subtitle>
|
||||
<Subtitle
|
||||
color="uiTextHighContrast"
|
||||
className={styles.titleDesktop}
|
||||
>
|
||||
{isCancelled && !isMultiRoom
|
||||
? intl.formatMessage({ id: "Cancellation number" })
|
||||
: intl.formatMessage({ id: "Reference number" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{isCancelled && !isMultiRoom
|
||||
? cancellationNumber
|
||||
: confirmationNumber}
|
||||
</Subtitle>
|
||||
</div>
|
||||
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!allRoomsCancelled && (
|
||||
<div className={styles.referenceRow}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Guests" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{allRooms.length > 1
|
||||
? children > 0
|
||||
? adultsAndChildrenAndRoomsMsg
|
||||
: adultsAndRoomsMsg
|
||||
: children > 0
|
||||
? adultsAndChildrenMsg
|
||||
: adultsOnlyMsg}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Total" })}
|
||||
</Caption>
|
||||
{allRooms.some((room) => room.isCancelled) && (
|
||||
<div className={styles.referenceRow}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Cancellation" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p className={styles.cancelledRooms}>
|
||||
{isMultiRoom
|
||||
? `${cancelledRoomsMsg} ${intl.formatMessage({ id: "cancelled" })}`
|
||||
: roomCancelledRoomsMsg}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{!allRoomsCancelled && (
|
||||
<>
|
||||
<div className={styles.referenceRow}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Check-in" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{`${fromDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Check-out" })}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${toDate.format("HH:mm")}`}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
{booking.guaranteeInfo && !allRoomsCancelled && (
|
||||
<>
|
||||
<div className={styles.guaranteed}>
|
||||
<CheckCircleIcon color="green" height={20} width={20} />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.guaranteedText}>
|
||||
<strong>
|
||||
{intl.formatMessage({ id: "Booking guaranteed." })}
|
||||
</strong>{" "}
|
||||
{intl.formatMessage({
|
||||
id: "Your stay remains available for check-in after 18:00.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{totalPrice ? (
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{formatPrice(intl, totalPrice, currencyCode)}
|
||||
</Caption>
|
||||
) : (
|
||||
<SkeletonShimmer width="50px" height="18px" />
|
||||
)}
|
||||
<div className={styles.referenceRow}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Total" })}</p>
|
||||
</Typography>
|
||||
<TotalPrice variant="Title/Subtitle/md" />
|
||||
</div>
|
||||
{booking?.bookingCode && (
|
||||
{bookingCode && (
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption>{intl.formatMessage({ id: "Booking code" })}</Caption>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>{intl.formatMessage({ id: "Booking code" })}</p>
|
||||
</Typography>
|
||||
<IconChip color={"blue"} icon={<BookingCodeIcon color="blue" />}>
|
||||
<Caption className={styles.bookingCode} color="blue">
|
||||
<strong>{intl.formatMessage({ id: "Booking code" })}</strong>
|
||||
{booking.bookingCode}
|
||||
</Caption>
|
||||
</IconChip>
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && (
|
||||
<div className={styles.referenceRow}>
|
||||
<IconChip
|
||||
color={"red"}
|
||||
icon={<CrossCircleIcon width={20} height={20} color="red" />}
|
||||
>
|
||||
<Caption color={"red"}>
|
||||
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
|
||||
{intl.formatMessage({ id: "Cancelled" })}
|
||||
</Caption>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.bookingCode}>
|
||||
<strong>{intl.formatMessage({ id: "Booking code" })}</strong>:{" "}
|
||||
{bookingCode}
|
||||
</p>
|
||||
</Typography>
|
||||
</IconChip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actionArea}>
|
||||
<ManageStay
|
||||
booking={booking}
|
||||
hotel={hotel}
|
||||
user={user}
|
||||
setBookingStatus={setBookingStatus}
|
||||
bookingStatus={bookingStatus}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
<Button fullWidth intent="secondary" asChild>
|
||||
<Button fullWidth intent="secondary" asChild size="small">
|
||||
<Link href={directionsUrl} target="_blank">
|
||||
{intl.formatMessage({ id: "Get directions" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{booking.isModifiable && (
|
||||
<Caption className={styles.note} color="uiTextHighContrast">
|
||||
{booking.rateDefinition.generalTerms.map((term) => (
|
||||
<span key={term}>{term} </span>
|
||||
))}
|
||||
</Caption>
|
||||
{isMultiRoom && (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.note}>
|
||||
{intl.formatMessage({ id: "Multi-room stay" })}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
{isModifiable && (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p
|
||||
className={`${styles.note} ${allRoomsCancelled ? styles.cancelledNote : ""}`}
|
||||
>
|
||||
{booking.rateDefinition.generalTerms.map((term) => (
|
||||
<span key={term}>
|
||||
{term}
|
||||
{term.endsWith(".") ? " " : ". "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,25 +19,32 @@
|
||||
margin-bottom: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.cancelledRooms {
|
||||
color: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
.actionArea {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Spacing-x2);
|
||||
margin: var(--Spacing-x4) 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.referenceCard .note {
|
||||
.note {
|
||||
text-align: center;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cancelledNote {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.titleDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bookingCode {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
color: var(--UI-Semantic-Information);
|
||||
}
|
||||
|
||||
.guaranteed {
|
||||
@@ -49,14 +56,20 @@
|
||||
padding: var(--Spacing-x1);
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.guaranteedText {
|
||||
color: var(--Surface-Feedback-Succes-Accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.actionArea {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.titleMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.titleDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { DiamondIcon, EditIcon } from "@/components/Icons"
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface GuestDetailsProps {
|
||||
user: User | null
|
||||
booking: BookingConfirmation["booking"]
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export default function GuestDetails({
|
||||
user,
|
||||
booking,
|
||||
isMobile = false,
|
||||
}: GuestDetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [guestDetails, setGuestDetails] = useState<
|
||||
BookingConfirmation["booking"]["guest"]
|
||||
>(booking.guest)
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: booking.guest.firstName ?? "",
|
||||
lastName: booking.guest.lastName ?? "",
|
||||
email: booking.guest.email ?? "",
|
||||
phoneNumber: booking.guest.phoneNumber ?? "",
|
||||
countryCode: booking.guest.countryCode ?? "",
|
||||
},
|
||||
})
|
||||
const containerClass = isMobile
|
||||
? styles.guestDetailsMobile
|
||||
: styles.guestDetailsDesktop
|
||||
|
||||
const isMemberBooking =
|
||||
booking.guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: () => {
|
||||
setIsLoading(false)
|
||||
toast.success(intl.formatMessage({ id: "Guest details updated" }))
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoading(false)
|
||||
toast.error(intl.formatMessage({ id: "Failed to update guest details" }))
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
if (booking.confirmationNumber) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
setGuestDetails({ ...guestDetails, ...data })
|
||||
}
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.pathname,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{isMemberBooking && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowTitle}>
|
||||
<Caption
|
||||
type="bold"
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
textAlign="center"
|
||||
>
|
||||
{intl.formatMessage({ id: "Your member tier" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership!.membershipLevel}
|
||||
color="red"
|
||||
height={isMobile ? "40" : "20"}
|
||||
width={isMobile ? "80" : "40"}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
{isMobile && (
|
||||
<div>
|
||||
<DiamondIcon color="uiTextHighContrast" />
|
||||
</div>
|
||||
)}
|
||||
<Caption
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Total points" })}
|
||||
</Caption>
|
||||
|
||||
<Body color="uiTextHighContrast" className={styles.totalPointsText}>
|
||||
{user.membership!.currentPoints}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{guestDetails.firstName} {guestDetails.lastName}
|
||||
</Body>
|
||||
{isMemberBooking && (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Member no. {nr}" },
|
||||
{
|
||||
nr: user.membership!.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
)}
|
||||
<Caption color="uiTextHighContrast">{guestDetails.email}</Caption>
|
||||
<Caption color="uiTextHighContrast">{guestDetails.phoneNumber}</Caption>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={isMobile ? "secondary" : "text"}
|
||||
onClick={handleModifyMemberDetails}
|
||||
>
|
||||
<EditIcon color="burgundy" width={20} height={20} />
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({ id: "Modify guest details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="text"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
>
|
||||
<EditIcon color="burgundy" width={20} height={20} />
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({ id: "Modify guest details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({ id: "Modify guest details" })}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
<ModifyContact
|
||||
guest={booking.guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({ id: "Save updates" })
|
||||
: intl.formatMessage({ id: "Confirm" }),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
},
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({ id: "Back" })
|
||||
: intl.formatMessage({ id: "Cancel" }),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import {
|
||||
BedDoubleIcon,
|
||||
BookingCodeIcon,
|
||||
CoffeeIcon,
|
||||
ContractIcon,
|
||||
DoorOpenIcon,
|
||||
PersonIcon,
|
||||
} from "@/components/Icons"
|
||||
import RocketLaunch from "@/components/Icons/Refresh"
|
||||
import Image from "@/components/Image"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import GuestDetails from "./GuestDetails"
|
||||
import PriceDetailsTable from "./PriceDetailsTable"
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import type { Hotel, Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface RoomProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
room:
|
||||
| (Room & {
|
||||
bedType: Room["roomTypes"][number]
|
||||
})
|
||||
| null
|
||||
hotel: Hotel
|
||||
user: User | null
|
||||
}
|
||||
|
||||
function hasBreakfastPackage(
|
||||
packages: BookingConfirmation["booking"]["packages"]
|
||||
) {
|
||||
return packages.some(
|
||||
(p) =>
|
||||
p.code === BreakfastPackageEnum.REGULAR_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.SPECIAL_PACKAGE_BREAKFAST
|
||||
)
|
||||
}
|
||||
|
||||
function RoomHeader({
|
||||
room,
|
||||
hotel,
|
||||
}: {
|
||||
room: RoomProps["room"]
|
||||
hotel: Hotel
|
||||
}) {
|
||||
if (!room) return null
|
||||
|
||||
return (
|
||||
<div className={styles.roomHeader}>
|
||||
<Subtitle textTransform="uppercase" color="burgundy">
|
||||
{room.name}
|
||||
</Subtitle>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotel.operaId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
intent="text"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Room({ booking, room, hotel, user }: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
if (!room) return null
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
|
||||
const mainBedWidthValueMsg = intl.formatMessage(
|
||||
{ id: "{value} cm" },
|
||||
{
|
||||
value: room.bedType.mainBed.widthRange.min,
|
||||
}
|
||||
)
|
||||
|
||||
const mainBedWidthRangeMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{min}–{max} cm",
|
||||
},
|
||||
{
|
||||
min: room.bedType.mainBed.widthRange.min,
|
||||
max: room.bedType.mainBed.widthRange.max,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{adults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{
|
||||
adults: booking.adults,
|
||||
}
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: booking.childrenAges.length,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsOnlyMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className={styles.room}>
|
||||
<RoomHeader room={room} hotel={hotel} />
|
||||
<div className={styles.booking}>
|
||||
<div className={styles.chipContainer}>
|
||||
{booking.packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = getIconForFeatureCode(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
return (
|
||||
<span className={styles.chip} key={item.code}>
|
||||
<Icon width={16} height={16} color="burgundy" />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.images}>
|
||||
{room.images.slice(0, 2).map((image) => (
|
||||
<Image
|
||||
key={image.imageSizes.large}
|
||||
src={image.imageSizes.large}
|
||||
className={styles.image}
|
||||
alt={room?.name ?? hotel.name}
|
||||
width={700}
|
||||
height={450}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<div className={styles.bookingDetails}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<ContractIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Booking policy" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.rateDefinition.cancellationText}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<RocketLaunch color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Rebooking" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Until {time}, {date}" },
|
||||
{ time: "18:00", date: fromDate.format("dddd D MMM") }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
{booking.packages.some((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
) && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<DoorOpenIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Room type" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
)
|
||||
.map((item) => item.description)
|
||||
.join(", ")}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<PersonIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Guests" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.childrenAges.length > 0
|
||||
? adultsAndChildrenMsg
|
||||
: adultsOnlyMsg}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<CoffeeIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hasBreakfastPackage(booking.packages)
|
||||
? intl.formatMessage({ id: "Included" })
|
||||
: intl.formatMessage({ id: "Not included" })}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<BedDoubleIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Bed preference" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{room.bedType.mainBed.description}
|
||||
{room.bedType.mainBed.widthRange.min ===
|
||||
room.bedType.mainBed.widthRange.max
|
||||
? ` (${mainBedWidthValueMsg})`
|
||||
: ` (${mainBedWidthRangeMsg})`}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GuestDetails user={user} booking={booking} isMobile={false} />
|
||||
</div>
|
||||
|
||||
<div className={styles.bookingInformation}>
|
||||
{booking?.bookingCode && (
|
||||
<IconChip color={"blue"} icon={<BookingCodeIcon color="blue" />}>
|
||||
<Caption className={styles.bookingCode} color="blue">
|
||||
<strong>{intl.formatMessage({ id: "Booking code" })}</strong>
|
||||
{booking.bookingCode}
|
||||
</Caption>
|
||||
</IconChip>
|
||||
)}
|
||||
<div className={styles.priceDetails}>
|
||||
<div className={styles.price}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Room total" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<PriceDetailsModal>
|
||||
<PriceDetailsTable
|
||||
bookingCode={booking.bookingCode}
|
||||
fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")}
|
||||
rooms={[
|
||||
{
|
||||
adults: booking.adults,
|
||||
childrenInRoom: undefined,
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
},
|
||||
perStay: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
},
|
||||
},
|
||||
roomType: room.name,
|
||||
},
|
||||
]}
|
||||
toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")}
|
||||
totalPrice={{
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
}}
|
||||
vat={booking.vatPercentage}
|
||||
/>
|
||||
</PriceDetailsModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<GuestDetails user={user} booking={booking} isMobile={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import Price, { type Variant } from "../../Price"
|
||||
|
||||
export default function TotalPrice({ variant }: { variant: Variant }) {
|
||||
const { totalPrice } = useMyStayTotalPriceStore()
|
||||
|
||||
return <Price price={totalPrice} variant={variant} />
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import MultiRoom from "../MultiRoom"
|
||||
import MultiRoomSkeleton from "../MultiRoom/MultiRoomSkeleton"
|
||||
import PriceDetails from "../PriceDetails"
|
||||
import { SingleRoom } from "../SingleRoom"
|
||||
import TotalPrice from "./TotalPrice"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import { type Hotel, type Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface RoomsProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
room:
|
||||
| (Room & {
|
||||
bedType: Room["roomTypes"][number]
|
||||
})
|
||||
| null
|
||||
hotel: Hotel
|
||||
user: User | null
|
||||
}
|
||||
|
||||
export default async function Rooms({
|
||||
booking,
|
||||
room,
|
||||
hotel,
|
||||
user,
|
||||
}: RoomsProps) {
|
||||
const intl = await getIntl()
|
||||
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linkedBookingPromises = booking.linkedReservations
|
||||
? booking.linkedReservations.map((linkedBooking) => {
|
||||
return getBookingConfirmation(linkedBooking.confirmationNumber)
|
||||
})
|
||||
: []
|
||||
|
||||
const isMultiRoom = booking.linkedReservations.length > 0
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{isMultiRoom && (
|
||||
<Typography variant="Title/sm">
|
||||
<h2 className={styles.title}>
|
||||
{intl.formatMessage({ id: "Your rooms" })}
|
||||
</h2>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
{!isMultiRoom ? (
|
||||
<SingleRoom
|
||||
bedType={room.bedType}
|
||||
image={room.images[0]}
|
||||
hotel={hotel}
|
||||
user={user}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.roomsContainer}>
|
||||
<MultiRoom booking={booking} room={room} user={user} />
|
||||
{booking.linkedReservations.map((linkedRes, index) => (
|
||||
<div
|
||||
key={linkedRes.confirmationNumber}
|
||||
className={styles.roomWrapper}
|
||||
>
|
||||
<Suspense fallback={<MultiRoomSkeleton />}>
|
||||
<MultiRoom
|
||||
bookingPromise={linkedBookingPromises[index]}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isMultiRoom && (
|
||||
<div className={styles.totalContainer}>
|
||||
<div className={styles.total}>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>{intl.formatMessage({ id: "Booking total" })}:</p>
|
||||
</Typography>
|
||||
<TotalPrice variant="Title/Subtitle/lg" />
|
||||
</div>
|
||||
|
||||
<PriceDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.roomsContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.roomWrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roomWrapper > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.totalContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.roomsContainer {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.roomsContainer:has(> *:nth-child(3):last-child) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.totalContainer {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,11 @@ export default function ToggleSidePeek({
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.roomDetails,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import {
|
||||
BedDoubleIcon,
|
||||
BookingCodeIcon,
|
||||
CoffeeIcon,
|
||||
ContractIcon,
|
||||
DoorOpenIcon,
|
||||
PersonIcon,
|
||||
} from "@/components/Icons"
|
||||
import Refresh from "@/components/Icons/Refresh"
|
||||
import Image from "@/components/Image"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import GuestDetails from "../GuestDetails"
|
||||
import Price from "../Price"
|
||||
import PriceDetails from "../PriceDetails"
|
||||
import { hasBreakfastPackage } from "../utils/hasBreakfastPackage"
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Hotel, Room } from "@/types/hotel"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface RoomProps {
|
||||
bedType: Room["roomTypes"][number]
|
||||
image: Room["images"][number]
|
||||
hotel: Hotel
|
||||
user: User | null
|
||||
}
|
||||
|
||||
export function SingleRoom({ bedType, image, hotel, user }: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const updateBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateBookedRoom
|
||||
)
|
||||
|
||||
if (!bookedRoom.roomNumber) {
|
||||
return (
|
||||
<div className={styles.room}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<SkeletonShimmer width="100%" height="750px" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fromDate = dt(bookedRoom.checkInDate).locale(lang)
|
||||
|
||||
const {
|
||||
adults,
|
||||
childrenAges,
|
||||
confirmationNumber,
|
||||
bookingCode,
|
||||
roomPrice,
|
||||
packages,
|
||||
rateDefinition,
|
||||
isCancelled,
|
||||
} = bookedRoom
|
||||
|
||||
const mainBedWidthValueMsg = intl.formatMessage(
|
||||
{ id: "{value} cm" },
|
||||
{
|
||||
value: bedType.mainBed.widthRange.min,
|
||||
}
|
||||
)
|
||||
|
||||
const mainBedWidthRangeMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{min}–{max} cm",
|
||||
},
|
||||
{
|
||||
min: bedType.mainBed.widthRange.min,
|
||||
max: bedType.mainBed.widthRange.max,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{adults, plural, one {# adult} other {# adults}}" },
|
||||
{
|
||||
adults,
|
||||
}
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: childrenAges.length,
|
||||
}
|
||||
)
|
||||
|
||||
const adultsOnlyMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
|
||||
const hasPackages = packages?.some((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(item.code)
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className={styles.room}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p className={styles.roomName}>{bookedRoom.roomName}</p>
|
||||
</Typography>
|
||||
<div className={styles.roomHeader}>
|
||||
<div className={styles.roomHeaderContent}>
|
||||
<div className={styles.chip}>
|
||||
<Typography variant="Tag/sm">
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{
|
||||
roomIndex: bookedRoom.roomNumber,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.reference}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{intl.formatMessage({ id: "Reference" })}:</span>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{confirmationNumber}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sidePeek}>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotel.operaId}
|
||||
roomTypeCode={bookedRoom.roomTypeCode}
|
||||
intent="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.booking}>
|
||||
<div
|
||||
className={`${styles.content} ${isCancelled ? styles.cancelled : ""}`}
|
||||
>
|
||||
{packages &&
|
||||
packages.some((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(item.code)
|
||||
) && (
|
||||
<div className={styles.packages}>
|
||||
{packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(item.code)
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = getIconForFeatureCode(item.code)
|
||||
return (
|
||||
<span className={styles.package} key={item.code}>
|
||||
<Icon width={16} height={16} color="burgundy" />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
key={image.imageSizes.small}
|
||||
src={image.imageSizes.small}
|
||||
className={styles.image}
|
||||
alt={bookedRoom.roomName}
|
||||
width={640}
|
||||
height={960}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<div className={styles.bookingDetails}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<PersonIcon color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Guests" })}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{childrenAges.length > 0
|
||||
? adultsAndChildrenMsg
|
||||
: adultsOnlyMsg}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<ContractIcon color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Terms" })}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{rateDefinition.cancellationText}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<Refresh color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Modify By" })}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Until {time}, {date}" },
|
||||
{ time: "18:00", date: fromDate.format("dddd D MMM") }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<CoffeeIcon color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Breakfast" })}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{hasBreakfastPackage(
|
||||
packages?.map((pkg) => ({
|
||||
code: pkg.code,
|
||||
})) ?? []
|
||||
)
|
||||
? intl.formatMessage({ id: "Included" })
|
||||
: intl.formatMessage({ id: "Not included" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{hasPackages && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<DoorOpenIcon color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({ id: "Room classification" })}
|
||||
</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{packages!
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code
|
||||
)
|
||||
)
|
||||
.map((item) => item.description)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<BedDoubleIcon color="grey80" width={20} height={20} />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{intl.formatMessage({ id: "Bed preference" })}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">
|
||||
{bedType.mainBed.description}
|
||||
{bedType.mainBed.widthRange.min ===
|
||||
bedType.mainBed.widthRange.max
|
||||
? ` (${mainBedWidthValueMsg})`
|
||||
: ` (${mainBedWidthRangeMsg})`}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.guestDetailsDesktopWrapper}>
|
||||
<GuestDetails
|
||||
user={user}
|
||||
booking={bookedRoom}
|
||||
updateRoom={updateBookedRoom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.bookingInformation}>
|
||||
{bookingCode && (
|
||||
<IconChip color={"blue"} icon={<BookingCodeIcon color="blue" />}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.bookingCode}>
|
||||
<strong>
|
||||
{intl.formatMessage({ id: "Booking code" })}
|
||||
</strong>
|
||||
: {bookingCode}
|
||||
</p>
|
||||
</Typography>
|
||||
</IconChip>
|
||||
)}
|
||||
<div className={styles.priceDetails}>
|
||||
<div className={styles.price}>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Room total" })}
|
||||
</p>
|
||||
</Typography>
|
||||
<Price
|
||||
price={isCancelled ? 0 : roomPrice.perStay.local.price}
|
||||
variant="Title/Subtitle/lg"
|
||||
isMember={rateDefinition.isMemberRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PriceDetails />
|
||||
<div className={styles.guestDetailsMobileWrapper}>
|
||||
<GuestDetails
|
||||
user={user}
|
||||
booking={bookedRoom}
|
||||
updateRoom={updateBookedRoom}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,102 +7,87 @@
|
||||
}
|
||||
|
||||
.bookingCode {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
color: var(--UI-Semantic-Information);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.room {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.roomName {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x1);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.roomHeader {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
.roomHeaderContent {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.sidePeek {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
width: var(--max-width-content);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.booking {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
.chipContainer {
|
||||
position: absolute;
|
||||
top: 300px;
|
||||
left: 25px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
.cancelled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Scandic-Peach-30);
|
||||
color: var(--Scandic-Red-100);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.reference {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.packages {
|
||||
position: absolute;
|
||||
top: 180px;
|
||||
left: 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.package {
|
||||
background-color: var(--Main-Grey-White);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.images {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-columns: 1fr;
|
||||
height: 210px;
|
||||
.imageContainer {
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.images {
|
||||
height: 320px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
height: 220px;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.image {
|
||||
height: 100%;
|
||||
}
|
||||
.image:last-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -121,42 +106,22 @@
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.roomDetails {
|
||||
grid-template-columns: minmax(0, 700px) 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.bookingDetails {
|
||||
max-width: 100%;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingDetails {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.row {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -172,136 +137,127 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.rowTitle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rowContent {
|
||||
padding-left: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.guestDetailsDesktop {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guestDetailsDesktop {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.guestDetailsMobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
background-color: var(--Main-Brand-PalePeach);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .rowTitle {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .userDetails {
|
||||
width: calc(100% - var(--Spacing-x4) - var(--Spacing-x4));
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .totalPoints {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .totalPointsText {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .guest {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guestDetailsMobile {
|
||||
display: none;
|
||||
}
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.guest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bookingInformation {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingInformation {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
background-color: var(--Scandic-Beige-10);
|
||||
margin: 0 var(--Spacing-x2);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.priceDetails {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
width: calc(100% - var(--Spacing-x4));
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.priceDetails {
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.guestDetailsMobileWrapper {
|
||||
display: block;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.guestDetailsDesktopWrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.room {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.roomName {
|
||||
padding: 0;
|
||||
}
|
||||
.roomHeader {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
}
|
||||
.sidePeek {
|
||||
display: block;
|
||||
}
|
||||
.booking {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
}
|
||||
.content {
|
||||
padding: var(--Spacing-x2);
|
||||
grid-template-columns: 3fr 2fr;
|
||||
width: var(--max-width-content);
|
||||
}
|
||||
.packages {
|
||||
top: 620px;
|
||||
left: 25px;
|
||||
}
|
||||
.imageContainer {
|
||||
height: 640px;
|
||||
}
|
||||
.image {
|
||||
height: 100%;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
.bookingDetails {
|
||||
padding: 0;
|
||||
}
|
||||
.row {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rowTitle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.bookingInformation {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
.priceDetails {
|
||||
margin: 0 0 0 auto;
|
||||
width: auto;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.price {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.guestDetailsMobileWrapper {
|
||||
display: none;
|
||||
}
|
||||
.guestDetailsDesktopWrapper {
|
||||
display: block;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ const loggedOut: Guest = {
|
||||
email: "logged+out@scandichotels.com",
|
||||
firstName: "Anonymous",
|
||||
lastName: "Booking",
|
||||
membershipNumber: null,
|
||||
membershipNumber: "",
|
||||
phoneNumber: "+46701234567",
|
||||
countryCode: "SE",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { homeHrefs } from "@/constants/homeHrefs"
|
||||
import { env } from "@/env/server"
|
||||
@@ -19,7 +18,6 @@ import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import AdditionalInfoForm from "../FindMyBooking/AdditionalInfoForm"
|
||||
import LinkedReservationSkeleton from "./LinkedReservation/LinkedReservationSkeleton"
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
@@ -28,10 +26,9 @@ import accessBooking, {
|
||||
import { Ancillaries } from "./Ancillaries"
|
||||
import BookingSummary from "./BookingSummary"
|
||||
import { Header } from "./Header"
|
||||
import LinkedReservation from "./LinkedReservation"
|
||||
import Promo from "./Promo"
|
||||
import { ReferenceCard } from "./ReferenceCard"
|
||||
import { Room } from "./Room"
|
||||
import Rooms from "./Rooms"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
@@ -53,12 +50,6 @@ export async function MyStay({ refId }: { refId: string }) {
|
||||
|
||||
const access = accessBooking(booking.guest, lastName, user, bv)
|
||||
if (access === ACCESS_GRANTED) {
|
||||
const linkedBookingPromises = booking.linkedReservations
|
||||
? booking.linkedReservations.map((linkedBooking) => {
|
||||
return getBookingConfirmation(linkedBooking.confirmationNumber)
|
||||
})
|
||||
: []
|
||||
|
||||
const lang = getLang()
|
||||
const ancillaryPackages = await getAncillaryPackages({
|
||||
fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
|
||||
@@ -94,9 +85,10 @@ export async function MyStay({ refId }: { refId: string }) {
|
||||
<ReferenceCard
|
||||
booking={booking}
|
||||
hotel={hotel}
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
room={room}
|
||||
isLoggedIn={!!user}
|
||||
/>
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
@@ -108,28 +100,18 @@ export async function MyStay({ refId }: { refId: string }) {
|
||||
refId={refId}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Room booking={booking} room={room} hotel={hotel} user={user} />
|
||||
{booking.linkedReservations.map((linkedRes, index) => (
|
||||
<Suspense
|
||||
key={linkedRes.confirmationNumber}
|
||||
fallback={<LinkedReservationSkeleton />}
|
||||
>
|
||||
<LinkedReservation
|
||||
bookingPromise={linkedBookingPromises[index]}
|
||||
index={index}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
<BookingSummary booking={booking} hotel={hotel} room={room} />
|
||||
|
||||
<Rooms booking={booking} room={room} hotel={hotel} user={user} />
|
||||
|
||||
<BookingSummary hotel={hotel} />
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "Book another stay" })}
|
||||
href={`${homeHrefs[env.NODE_ENV][lang]}?hotel=${hotel.operaId}`}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
text={intl.formatMessage({
|
||||
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
buttonText={intl.formatMessage({ id: "Explore Scandic hotels" })}
|
||||
href={`${homeHrefs[env.NODE_ENV][lang]}?hotel=${hotel.operaId}`}
|
||||
image={hotel.hotelContent.images}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -44,13 +44,6 @@
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
@@ -85,12 +78,6 @@
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.ancillariesSkeleton {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.paymentDetailsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -106,3 +93,14 @@
|
||||
.logIn {
|
||||
padding: var(--Spacing-x5) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.ancillariesSkeleton {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStaySkeleton() {
|
||||
export function MyStaySkeleton() {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerSkeleton}>
|
||||
@@ -25,7 +27,7 @@ export async function MyStaySkeleton() {
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<div className={styles.roomSkeleton}>
|
||||
<div>
|
||||
<SkeletonShimmer width="100%" height="700px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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",
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -1,63 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
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[]
|
||||
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]
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing room
|
||||
newRooms[existingIndex] = room
|
||||
} else {
|
||||
// Add new room
|
||||
newRooms.push(room)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
interface RoomPrice {
|
||||
id: string
|
||||
totalPrice: number
|
||||
currencyCode: string
|
||||
isMainBooking?: boolean
|
||||
}
|
||||
|
||||
interface MyStayTotalPriceState {
|
||||
rooms: RoomPrice[]
|
||||
totalPrice: number
|
||||
currencyCode: string
|
||||
actions: {
|
||||
// Add a single room price
|
||||
setRoomPrice: (room: RoomPrice) => void
|
||||
|
||||
// Get the calculated total
|
||||
getTotalPrice: () => number
|
||||
}
|
||||
}
|
||||
|
||||
export const useMyStayTotalPriceStore = create<MyStayTotalPriceState>(
|
||||
(set, get) => ({
|
||||
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]
|
||||
|
||||
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
|
||||
}
|
||||
return sum
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
rooms: newRooms,
|
||||
totalPrice: total,
|
||||
currencyCode,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getTotalPrice: () => {
|
||||
return get().totalPrice
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
const bedTypeToMapEnum: Record<string, ChildBedMapEnum> = {
|
||||
Crib: ChildBedMapEnum.IN_CRIB,
|
||||
ExtraBed: ChildBedMapEnum.IN_EXTRA_BED,
|
||||
ParentsBed: ChildBedMapEnum.IN_ADULTS_BED,
|
||||
Unknown: ChildBedMapEnum.UNKNOWN,
|
||||
}
|
||||
|
||||
export function convertToChildType(
|
||||
childrenAges: number[],
|
||||
childBedPreferences: BookingConfirmation["booking"]["childBedPreferences"]
|
||||
): Child[] {
|
||||
return childBedPreferences.map((preference, index) => ({
|
||||
age: childrenAges[index],
|
||||
bed: bedTypeToMapEnum[preference.bedType] ?? ChildBedMapEnum.UNKNOWN,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export function hasBreakfastPackage(
|
||||
packages: {
|
||||
code: string
|
||||
}[]
|
||||
) {
|
||||
return packages.some(
|
||||
(p) =>
|
||||
p.code === BreakfastPackageEnum.REGULAR_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.SPECIAL_PACKAGE_BREAKFAST
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { formatChildBedPreferences } from "../utils"
|
||||
import { convertToChildType } from "./convertToChildType"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import { PackageTypeEnum } from "@/types/enums/packages"
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { Room as MyStayRoom } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
interface MapRoomDetailsParams {
|
||||
booking: BookingConfirmation["booking"]
|
||||
room: (Room & { bedType: Room["roomTypes"][number] }) | null
|
||||
roomNumber: number
|
||||
}
|
||||
|
||||
export function mapRoomDetails({
|
||||
booking,
|
||||
room,
|
||||
roomNumber,
|
||||
}: MapRoomDetailsParams): MyStayRoom {
|
||||
const nights = dt(booking.checkOutDate)
|
||||
.startOf("day")
|
||||
.diff(dt(booking.checkInDate).startOf("day"), "days")
|
||||
|
||||
const breakfastPkg = booking.packages.find(
|
||||
(pkg) =>
|
||||
pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST ||
|
||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ||
|
||||
pkg.code === BreakfastPackageEnum.SPECIAL_PACKAGE_BREAKFAST
|
||||
)
|
||||
|
||||
const featuresPkg = booking.packages.filter(
|
||||
(pkg) =>
|
||||
pkg.code === RoomPackageCodeEnum.PET_ROOM ||
|
||||
pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM ||
|
||||
pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)
|
||||
|
||||
const breakfast: BreakfastPackage | false = breakfastPkg
|
||||
? {
|
||||
code: breakfastPkg.code,
|
||||
description: breakfastPkg.description,
|
||||
localPrice: {
|
||||
currency: breakfastPkg.currency,
|
||||
price: breakfastPkg.unitPrice,
|
||||
totalPrice: breakfastPkg.totalPrice,
|
||||
},
|
||||
requestedPrice: {
|
||||
currency: breakfastPkg.currency,
|
||||
price: breakfastPkg.unitPrice,
|
||||
totalPrice: breakfastPkg.totalPrice,
|
||||
},
|
||||
packageType: PackageTypeEnum.BreakfastAdult,
|
||||
}
|
||||
: false
|
||||
|
||||
const isCancelled = booking.reservationStatus === BookingStatusEnum.Cancelled
|
||||
|
||||
const childrenAsString = formatChildBedPreferences({
|
||||
childrenAges: booking.childrenAges,
|
||||
childBedPreferences: booking.childBedPreferences,
|
||||
})
|
||||
|
||||
const childrenInRoom = convertToChildType(
|
||||
booking.childrenAges,
|
||||
booking.childBedPreferences
|
||||
)
|
||||
|
||||
return {
|
||||
hotelId: booking.hotelId,
|
||||
roomTypeCode: booking.roomTypeCode,
|
||||
adults: booking.adults,
|
||||
childrenAges: booking.childrenAges,
|
||||
checkInDate: booking.checkInDate,
|
||||
checkOutDate: booking.checkOutDate,
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
cancellationNumber: booking.cancellationNumber,
|
||||
createDateTime: booking.createDateTime,
|
||||
rateDefinition: booking.rateDefinition,
|
||||
guaranteeInfo: booking.guaranteeInfo,
|
||||
linkedReservations: booking.linkedReservations,
|
||||
bookingCode: booking.bookingCode,
|
||||
isModifiable: booking.isModifiable,
|
||||
isCancelable: booking.isCancelable,
|
||||
multiRoom: booking.multiRoom,
|
||||
canChangeDate: booking.canChangeDate,
|
||||
guest: booking.guest,
|
||||
currencyCode: booking.currencyCode,
|
||||
vatPercentage: booking.vatPercentage,
|
||||
mainRoom: booking.mainRoom,
|
||||
roomName: room?.name ?? "",
|
||||
roomNumber,
|
||||
isCancelled,
|
||||
childrenInRoom,
|
||||
childrenAsString,
|
||||
terms: booking.rateDefinition.cancellationText,
|
||||
packages: featuresPkg.map((pkg) => ({
|
||||
code: pkg.code as RoomPackageCodeEnum,
|
||||
description: pkg.description,
|
||||
inventories: [],
|
||||
itemCode: "",
|
||||
localPrice: {
|
||||
currency: pkg.currency,
|
||||
price: pkg.unitPrice,
|
||||
totalPrice: pkg.totalPrice,
|
||||
},
|
||||
requestedPrice: {
|
||||
currency: pkg.currency,
|
||||
price: pkg.unitPrice,
|
||||
totalPrice: pkg.totalPrice,
|
||||
},
|
||||
})),
|
||||
bedType: {
|
||||
description: room?.bedType.mainBed.description ?? "",
|
||||
roomTypeCode: room?.bedType.code ?? "",
|
||||
},
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: isCancelled ? 0 : booking.roomPrice / nights,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
perStay: {
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: isCancelled ? 0 : booking.roomPrice,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
},
|
||||
breakfast,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
||||
import HotelSidePeek from "@/components/SidePeeks/HotelSidePeek"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -10,8 +11,12 @@ import useLang from "@/hooks/useLang"
|
||||
export default function HotelReservationSidePeek() {
|
||||
const activeSidePeek = useSidePeekStore((state) => state.activeSidePeek)
|
||||
const hotelId = useSidePeekStore((state) => state.hotelId)
|
||||
const confirmationNumber = useSidePeekStore(
|
||||
(state) => state.confirmationNumber
|
||||
)
|
||||
const roomTypeCode = useSidePeekStore((state) => state.roomTypeCode)
|
||||
const showCTA = useSidePeekStore((state) => state.showCTA)
|
||||
const user = useSidePeekStore((state) => state.user)
|
||||
const close = useSidePeekStore((state) => state.closeSidePeek)
|
||||
const lang = useLang()
|
||||
|
||||
@@ -49,6 +54,15 @@ export default function HotelReservationSidePeek() {
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
{selectedRoom && (
|
||||
<BookedRoomSidePeek
|
||||
room={selectedRoom}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
user={user}
|
||||
confirmationNumber={confirmationNumber}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user