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:
Pontus Dreij
2025-03-24 09:30:10 +00:00
parent c5e294c7ea
commit 74c5b47319
117 changed files with 5899 additions and 1901 deletions

View File

@@ -10,6 +10,11 @@
display: flex;
justify-content: space-between;
}
.titleText {
color: var(--Scandic-Brand-Burgundy);
}
.ancillaries {
display: none;
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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",
}}
/>

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
.memberPrice {
color: var(--Scandic-Red-60);
}
.nonMemberPrice {
color: var(--Main-Grey-100);
}

View File

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

View File

@@ -29,6 +29,7 @@
display: flex;
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.priceDetailsTable {
min-width: 512px;

View File

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

View File

@@ -0,0 +1,4 @@
.priceDetailsModal {
display: flex;
justify-content: flex-end;
}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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} />
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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"

View File

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

View File

@@ -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;
}
}

View File

@@ -138,7 +138,7 @@ const loggedOut: Guest = {
email: "logged+out@scandichotels.com",
firstName: "Anonymous",
lastName: "Booking",
membershipNumber: null,
membershipNumber: "",
phoneNumber: "+46701234567",
countryCode: "SE",
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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