feat: refactor of my stay

This commit is contained in:
Simon Emanuelsson
2025-04-25 14:08:14 +02:00
committed by Simon.Emanuelsson
parent b5deb84b33
commit ec087a3d15
208 changed files with 5458 additions and 4569 deletions

View File

@@ -0,0 +1,19 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import CustomerSupportModal from "@/components/HotelReservation/MyStay/ReferenceCard/Actions/CustomerSupportModal"
import Button from "@/components/TempDesignSystem/Button"
export default function CustomerSupport() {
const intl = useIntl()
return (
<DialogTrigger>
<Button fullWidth intent="secondary" size="small">
{intl.formatMessage({ defaultMessage: "Customer Support" })}
</Button>
<CustomerSupportModal />
</DialogTrigger>
)
}

View File

@@ -0,0 +1,18 @@
div a.link {
align-items: center;
background-color: var(--Component-Button-Brand-Tertiary-Fill-Default);
border: 2px solid var(--Component-Button-Brand-Tertiary-Border-Default);
border-radius: var(--Corner-radius-rounded);
color: var(--Text-Inverted);
cursor: pointer;
display: flex;
gap: var(--Space-x1);
height: 48px;
justify-content: center;
padding: var(--Space-x2) var(--Space-x4);
transition: background-color 200ms ease;
&:hover {
background-color: var(--Component-Button-Brand-Tertiary-Fill-Hover);
}
}

View File

@@ -0,0 +1,21 @@
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import CustomerSupport from "./CustomerSupport"
import styles from "./cancelled.module.css"
export default function Cancelled() {
const intl = useIntl()
return (
<>
{/* (S) TODO - Link to where?? */}
<Link className={styles.link} href="#">
{intl.formatMessage({ defaultMessage: "Rebook" })}
</Link>
<CustomerSupport />
</>
)
}

View File

@@ -0,0 +1,30 @@
.links {
display: grid;
gap: var(--Space-x05);
}
.link {
align-items: center;
background: var(--Surface-Feedback-Information);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: var(--Corner-radius-Medium);
color: var(--Text-Interactive-Default);
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding: var(--Space-x3);
/* text-decoration: none; */
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-skip-ink: none;
text-decoration-thickness: auto;
text-underline-offset: auto;
text-underline-position: from-font;
}
@media screen and (min-width: 768px) {
.links {
gap: var(--Space-x3);
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,78 @@
"use client"
import Link from "next/link"
import { Dialog } from "react-aria-components"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import styles from "./customerSupport.module.css"
export default function CustomerSupportModal() {
const intl = useIntl()
const { email, phone } = useMyStayStore((state) => ({
email: state.hotel.contactInformation.email,
phone: state.hotel.contactInformation.phoneNumber,
}))
const title = intl.formatMessage({ defaultMessage: "Customer service" })
const contact = intl.formatMessage(
{
defaultMessage:
"Please call {phone} or email us at {email} for assistance with your order.",
},
{ email, phone }
)
return (
<Modal>
<Dialog>
{({ close }) => (
<Modal.Content>
<Modal.Content.Header handleClose={close} title={title}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{contact}</p>
</Typography>
</Modal.Content.Header>
<Modal.Content.Body>
<div className={styles.links}>
<Link className={styles.link} href={`tel:${phone}`}>
<MaterialIcon color="Icon/Interactive/Default" icon="call" />
<Typography variant="Title/Subtitle/md">
<span>
{intl.formatMessage({
defaultMessage: "Make a call",
})}
</span>
</Typography>
</Link>
<Link className={styles.link} href={`mailto:${email}`}>
<MaterialIcon color="Icon/Interactive/Default" icon="mail" />
<Typography variant="Title/Subtitle/md">
<span>
{intl.formatMessage({
defaultMessage: "Send an email",
})}
</span>
</Typography>
</Link>
</div>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={close}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary intent="secondary" onClick={close}>
{intl.formatMessage({ defaultMessage: "Close" })}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)}
</Dialog>
</Modal>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackMyStayPageLink } from "@/utils/tracking"
import styles from "./button.module.css"
export default function AddToCalendarButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const intl = useIntl()
function handleAddToCalendar() {
trackMyStayPageLink("add to calendar")
onPress()
}
return (
<ButtonRAC
className={styles.button}
isDisabled={disabled}
onPress={handleAddToCalendar}
>
<MaterialIcon color="Icon/Interactive/Default" icon="calendar_add_on" />
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.text}>
{intl.formatMessage({
defaultMessage: "Add to calendar",
})}
</span>
</Typography>
</ButtonRAC>
)
}

View File

@@ -0,0 +1,18 @@
.button {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
gap: var(--Space-x1);
padding: var(--Space-x1) 0;
width: 100%;
&:disabled {
color: var(--Scandic-Grey-40);
}
}
.text {
color: var(--Text-Interactive-Default);
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useMyStayStore } from "@/stores/my-stay"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
import { dateHasPassed } from "../utils"
import AddToCalendarButton from "./AddToCalendarButton"
import type { EventAttributes } from "ics"
export default function AddToCalendarAction() {
const { checkInDate, checkOutDate, createDateTime, hotel } = useMyStayStore(
(state) => ({
checkInDate: state.bookedRoom.checkInDate,
checkOutDate: state.bookedRoom.checkOutDate,
createDateTime: state.bookedRoom.createDateTime,
hotel: state.hotel,
})
)
const calendarEvent: EventAttributes = {
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(createDateTime),
description: hotel.hotelContent.texts.descriptions?.medium,
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(checkInDate),
startInputType: "utc",
status: "CONFIRMED",
title: hotel.name,
url: hotel.contactInformation.websiteUrl,
}
const disabled = dateHasPassed(
checkInDate,
hotel.hotelFacts.checkin.checkInTime
)
return (
<AddToCalendar
checkInDate={checkInDate}
event={calendarEvent}
hotelName={hotel.name}
renderButton={(onPress) => (
<AddToCalendarButton disabled={disabled} onPress={onPress} />
)}
/>
)
}

View File

@@ -0,0 +1,46 @@
"use client"
import { useIntl } from "react-intl"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
interface AlertsProps extends React.PropsWithChildren {
closeModal: () => void
}
export default function Alerts({ children, closeModal }: AlertsProps) {
const intl = useIntl()
const mainRoom = useMyStayStore((state) => state.bookedRoom)
if (!mainRoom) {
const title = intl.formatMessage({ defaultMessage: "Cancel stay" })
return (
<Modal.Content>
<Modal.Content.Header handleClose={closeModal} title={title} />
<Modal.Content.Body>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "Contact the person who booked the stay",
})}
text={intl.formatMessage({
defaultMessage:
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.",
})}
/>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
</Modal.Content.Footer>
</Modal.Content>
)
}
return <>{children}</>
}

View File

@@ -0,0 +1,78 @@
"use client"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useMyStayStore } from "@/stores/my-stay"
import PriceContainer from "@/components/HotelReservation/MyStay/ReferenceCard/PriceContainer"
import { formatPrice } from "@/utils/numberFormatting"
import type { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
export default function CancelStayPriceContainer() {
const intl = useIntl()
const { bookedRoom, nights, rooms } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
nights: dt(state.bookedRoom.checkOutDate)
.startOf("day")
.diff(dt(state.bookedRoom.checkInDate).startOf("day"), "days"),
rooms: state.rooms,
}))
const formRooms = useWatch<CancelStayFormValues>({ name: "rooms" })
if (!Array.isArray(formRooms)) {
return null
}
const { totalAdults, totalChildren } = formRooms.reduce(
(total, formRoom) => {
if (formRoom.checked) {
const room = rooms.find(
(r) => r.confirmationNumber === formRoom.confirmationNumber
)
if (room) {
total.totalAdults = total.totalAdults + room.adults
if (room.childrenInRoom.length) {
total.totalChildren =
total.totalChildren + room.childrenInRoom.length
}
}
}
return total
},
{ totalAdults: 0, totalChildren: 0 }
)
const adultsText = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: totalAdults }
)
const childrenText = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: totalChildren }
)
const nightsText = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
return (
<PriceContainer
adultsText={adultsText}
childrenText={childrenText}
nightsText={nightsText}
price={formatPrice(intl, 0, bookedRoom.currencyCode)}
text={intl.formatMessage({ defaultMessage: "Total due" })}
totalChildren={totalChildren}
/>
)
}

View File

@@ -0,0 +1,111 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import styles from "./multiroom.module.css"
import type { Room } from "@/types/stores/my-stay"
export default function Multiroom() {
const intl = useIntl()
const rooms = useMyStayStore((state) => state.rooms)
const notCancelableRooms = rooms.filter((r) => !r.isCancelable)
const cancelableRooms = rooms.filter((r) => !r.isCancelled && r.isCancelable)
const isSingleRoom = rooms.length === 1
if (isSingleRoom) {
return null
}
const myRooms = intl.formatMessage({ defaultMessage: "My rooms" })
const selectRoom = intl.formatMessage({
defaultMessage: "Select room",
})
const cannotBeCancelled = intl.formatMessage({
defaultMessage: "Cannot be cancelled",
})
if (notCancelableRooms.length) {
return (
<div className={styles.wrapper}>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
defaultMessage: "This stay has multiple terms.",
})}
</p>
</Typography>
<div className={styles.container}>
<List rooms={cancelableRooms} title={selectRoom} />
<List disabled rooms={notCancelableRooms} title={cannotBeCancelled} />
</div>
</div>
)
}
return <List rooms={cancelableRooms} title={myRooms} />
}
interface ListProps {
disabled?: boolean
rooms: Room[]
title: string
}
function List({ disabled = false, rooms, title }: ListProps) {
const intl = useIntl()
const refMsg = intl.formatMessage({ defaultMessage: "Ref" })
return (
<div className={styles.rooms}>
<Typography variant="Title/Overline/sm">
<p>{title}</p>
</Typography>
<ul className={styles.list}>
{rooms.map((room) => {
const roomNumber = room.roomNumber
return (
<li key={room.confirmationNumber}>
<Checkbox
className={styles.checkbox}
name={`rooms.${roomNumber - 1}.checked`}
registerOptions={{ disabled }}
>
<div className={styles.room}>
<div className={styles.chip}>
<Typography variant="Tag/sm">
<p className={styles.chipText}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</p>
</Typography>
</div>
<Typography variant="Title/Subtitle/md">
<p>{room.roomName}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
<p>
<strong>{refMsg}:</strong> {room.confirmationNumber}
</p>
</Typography>
</div>
</Checkbox>
</li>
)
})}
</ul>
</div>
)
}

View File

@@ -0,0 +1,74 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x5);
}
.rooms {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.list {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
list-style: none;
margin: 0;
padding: var(--Space-x05) 0 0;
}
.checkbox {
background: var(--Background-Primary);
border: 2px solid transparent;
border-radius: var(--Corner-radius-md);
padding: var(--Space-x2) var(--Space-x15);
}
.checkbox:has(input:checked) {
border-color: var(--Border-Interactive-Selected);
}
.checkbox:has(input:checked) span[class*="checkbox_checkbox_"] {
background-color: var(--Surface-UI-Fill-Active);
}
.checkbox:has(input:disabled) {
background-color: var(--Surface-UI-Fill-Disabled);
border: 1px solid var(--Border-Interactive-Disabled);
cursor: not-allowed;
}
.checkbox:has(input:disabled) .chip {
background-color: var(--Surface-UI-Fill-Disabled);
border: 1px solid var(--Text-Interactive-Disabled);
}
.checkbox:has(input:disabled) p {
color: var(--Text-Interactive-Disabled);
}
.room {
align-items: center;
display: grid;
gap: var(--Space-x1);
grid-template-columns: auto 1fr auto;
width: 100%;
}
.chip {
background-color: var(--Surface-Brand-Accent-Default);
border-radius: var(--Corner-radius-sm);
padding: var(--Space-x1);
}
.chipText {
color: var(--Text-Heading);
}

View File

@@ -0,0 +1,9 @@
.form {
display: flex;
flex-direction: column;
gap: var(--Space-x5);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,127 @@
"use client"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import useLang from "@/hooks/useLang"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import Multiroom from "./Multiroom"
import styles from "./confirmation.module.css"
import type { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
interface CancelStayConfirmationProps {
closeModal: () => void
onSubmit: (data: CancelStayFormValues) => void
}
export default function CancelStayConfirmation({
closeModal,
onSubmit,
}: CancelStayConfirmationProps) {
const intl = useIntl()
const lang = useLang()
const { handleSubmit } = useFormContext<CancelStayFormValues>()
const formRooms = useWatch<CancelStayFormValues>({ name: "rooms" })
const { fromDate, hotel, isCancelable, rate, toDate } = useMyStayStore(
(state) => ({
fromDate: state.bookedRoom.checkInDate,
hotel: state.hotel,
isCancelable: state.bookedRoom.isCancelable,
rate: state.bookedRoom.rate,
toDate: state.bookedRoom.checkOutDate,
})
)
const checkInDate = dt(fromDate).locale(lang).format("dddd D MMM YYYY")
const checkOutDate = dt(toDate).locale(lang).format("dddd D MMM YYYY")
const title = intl.formatMessage({ defaultMessage: "Cancel booking" })
const primaryLabel = intl.formatMessage({
defaultMessage: "Cancel stay",
})
const secondaryLabel = intl.formatMessage({
defaultMessage: "Back",
})
const notCancelableText = intl.formatMessage(
{
defaultMessage:
"Your stay has been booked with <strong>{rate}</strong> terms which unfortunately doesnt allow for cancellation.",
},
{
rate,
strong: (str) => <strong>{str}</strong>,
}
)
const text = intl.formatMessage(
{
defaultMessage:
"Are you sure you want to cancel your stay at <strong>{hotel}</strong> from <strong>{checkInDate}</strong> to <strong>{checkOutDate}?</strong> This can't be reversed.",
},
{
checkInDate,
checkOutDate,
hotel: hotel.name,
strong: (str) => <strong>{str}</strong>,
}
)
const isValid = Array.isArray(formRooms)
? formRooms.some((r) => r.checked)
: false
return (
<Modal.Content>
<Modal.Content.Header handleClose={closeModal} title={title}>
<Typography>
<p className={styles.textDefault}>
{isCancelable ? text : notCancelableText}
</p>
</Typography>
</Modal.Content.Header>
<Modal.Content.Body>
<form
className={styles.form}
id="cancel-stay"
onSubmit={handleSubmit(onSubmit)}
>
{isCancelable ? (
<>
<Multiroom />
<CancelStayPriceContainer />
</>
) : null}
</form>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{secondaryLabel}
</Modal.Content.Footer.Secondary>
{isCancelable ? (
<Modal.Content.Footer.Primary
disabled={!isValid}
form="cancel-stay"
intent="secondary"
type="submit"
>
{primaryLabel}
</Modal.Content.Footer.Primary>
) : (
<Modal.Content.Footer.Primary intent="secondary" onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Close" })}
</Modal.Content.Footer.Primary>
)}
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,9 @@
.toastContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,170 @@
"use client"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@/lib/trpc/client"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import CancelStayPriceContainer from "../CancelStayPriceContainer"
import styles from "./finalConfirmation.module.css"
import type { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
interface FinalConfirmationProps {
closeModal: () => void
}
export default function FinalConfirmation({
closeModal,
}: FinalConfirmationProps) {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const formRooms = useWatch<CancelStayFormValues>({ name: "rooms" })
const { bookedRoom, rooms } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
rooms: state.rooms,
}))
const cancelledStayMsg = intl.formatMessage({
defaultMessage: "Your stay was cancelled",
})
const sorryMsg = intl.formatMessage({
defaultMessage: "Were sorry that things didnt work out.",
})
const cancelBookingsMutation = trpc.booking.cancelMany.useMutation({
onSuccess(data, variables) {
const allCancellationsWentThrough = data.every((cancelled) => cancelled)
if (allCancellationsWentThrough) {
if (data.length === rooms.length) {
toast.success(
<div className={styles.toastContainer}>
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.textDefault}>{cancelledStayMsg}</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.textDefault}>{sorryMsg}</span>
</Typography>
</div>
)
} else {
const cancelledRooms = rooms.filter((r) =>
variables.confirmationNumbers.includes(r.confirmationNumber)
)
for (const cancelledRoom of cancelledRooms) {
toast.success(
<div className={styles.toastContainer}>
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.textDefault}>
<strong>
{intl.formatMessage(
{ defaultMessage: "{roomName} room was cancelled" },
{ roomName: cancelledRoom.roomName }
)}
</strong>
</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.textDefault}>
{intl.formatMessage({
defaultMessage:
"Your Stay is still active with the other room",
})}
</span>
</Typography>
</div>
)
}
}
} else {
toast.warning(
intl.formatMessage({
defaultMessage:
"Some rooms were cancelled successfully, but we encountered issues with others. Please contact customer service for assistance.",
})
)
}
utils.booking.get.invalidate({
confirmationNumber: bookedRoom.confirmationNumber,
})
utils.booking.linkedReservations.invalidate({
lang,
rooms: bookedRoom.linkedReservations,
})
closeModal()
},
onError() {
toast.error(
intl.formatMessage({
defaultMessage: "Something went wrong. Please try again later.",
})
)
},
})
function cancelBooking() {
if (Array.isArray(formRooms)) {
const confirmationNumbersToCancel = formRooms
.filter((r) => r.checked)
.map((r) => r.confirmationNumber)
if (confirmationNumbersToCancel.length) {
cancelBookingsMutation.mutate({
confirmationNumbers: confirmationNumbersToCancel,
language: lang,
})
}
} else {
toast.error(
intl.formatMessage({
defaultMessage: "Something went wrong. Please try again later.",
})
)
}
}
const confirm = intl.formatMessage({
defaultMessage: "Confirm cancellation",
})
const dontCancel = intl.formatMessage({
defaultMessage: "Don't cancel",
})
const text = intl.formatMessage({
defaultMessage: "Are you sure you want to continue with the cancellation?",
})
const title = intl.formatMessage({
defaultMessage: "Cancel booking",
})
return (
<Modal.Content>
<Modal.Content.Header handleClose={closeModal} title={title}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>{text}</p>
</Typography>
</Modal.Content.Header>
<Modal.Content.Body>
<CancelStayPriceContainer />
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{dontCancel}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary
disabled={cancelBookingsMutation.isPending}
onClick={cancelBooking}
>
{confirm}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,60 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useMyStayStore } from "@/stores/my-stay"
import CancelStayConfirmation from "./Confirmation"
import FinalConfirmation from "./FinalConfirmation"
import {
type CancelStayFormValues,
cancelStaySchema,
} from "@/types/components/hotelReservation/myStay/cancelStay"
interface StepsProps {
closeModal: () => void
}
export default function Steps({ closeModal }: StepsProps) {
const [confirm, setConfirm] = useState(false)
const rooms = useMyStayStore((state) => state.rooms)
const methods = useForm<CancelStayFormValues>({
mode: "onSubmit",
reValidateMode: "onChange",
resolver: zodResolver(cancelStaySchema),
values: {
rooms: rooms.map((room, idx) => ({
// Single room booking
checked: rooms.length === 1,
confirmationNumber: room.confirmationNumber,
id: idx + 1,
})),
},
})
function handleSubmit(data: CancelStayFormValues) {
const checkedRooms = data.rooms.filter((r) => r.checked)
if (checkedRooms.length) {
setConfirm(true)
}
}
const stepOne = !confirm
const stepTwo = confirm
return (
<FormProvider {...methods}>
{/* Step 1 */}
{stepOne ? (
<CancelStayConfirmation
closeModal={closeModal}
onSubmit={handleSubmit}
/>
) : null}
{/* Step 2 */}
{stepTwo ? <FinalConfirmation closeModal={closeModal} /> : null}
</FormProvider>
)
}

View File

@@ -0,0 +1,25 @@
.modalText {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.roomContainer {
display: flex;
padding: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
align-items: center;
gap: var(--Spacing-x1);
}
.roomInfo {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,28 @@
"use client"
import { Dialog, DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Alerts from "./Alerts"
import Steps from "./Steps"
export default function CancelStay() {
const intl = useIntl()
return (
<DialogTrigger>
<Modal.Button icon="cancel">
{intl.formatMessage({ defaultMessage: "Cancel stay" })}
</Modal.Button>
<Modal>
<Dialog>
{({ close }) => (
<Alerts closeModal={close}>
<Steps closeModal={close} />
</Alerts>
)}
</Dialog>
</Modal>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useIntl } from "react-intl"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function CannotChangeDate({
closeModal,
}: {
closeModal: () => void
}) {
const intl = useIntl()
return (
<Modal.Content>
<Modal.Content.Header
handleClose={closeModal}
title={intl.formatMessage({
defaultMessage: "New dates for the stay",
})}
/>
<Modal.Content.Body>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "Contact customer service",
})}
text={intl.formatMessage({
defaultMessage:
"Please contact customer service to update the dates.",
})}
/>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useIntl } from "react-intl"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function MultiRoomBooking({
closeModal,
}: {
closeModal: () => void
}) {
const intl = useIntl()
return (
<Modal.Content>
<Modal.Content.Header
handleClose={closeModal}
title={intl.formatMessage({
defaultMessage: "New dates for the stay",
})}
/>
<Modal.Content.Body>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "Contact customer service",
})}
text={intl.formatMessage({
defaultMessage:
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please contact customer service to update the dates.",
})}
/>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useIntl } from "react-intl"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function NotMainRoom({
closeModal,
}: {
closeModal: () => void
}) {
const intl = useIntl()
return (
<Modal.Content>
<Modal.Content.Header
handleClose={closeModal}
title={intl.formatMessage({
defaultMessage: "New dates for the stay",
})}
/>
<Modal.Content.Body>
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "Contact the person who booked the stay",
})}
text={intl.formatMessage({
defaultMessage:
"As this is a multiroom stay, any dates changes are applicable to all rooms. Please ask the person who booked the stay to contact customer service.",
})}
/>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import { useMyStayStore } from "@/stores/my-stay"
import CannotChangeDate from "./CannotChangeDate"
import MultiRoomBooking from "./MultiRoomBooking"
import NotMainRoom from "./NotMainRoom"
export default function Alerts({
children,
closeModal,
}: React.PropsWithChildren<{ closeModal: () => void }>) {
const { canChangeDate, mainRoom, multiRoom } = useMyStayStore((state) => ({
canChangeDate: state.bookedRoom.canChangeDate,
mainRoom: state.bookedRoom.mainRoom,
multiRoom: state.bookedRoom.multiRoom,
}))
if (multiRoom) {
return <MultiRoomBooking closeModal={closeModal} />
}
if (!mainRoom) {
return <NotMainRoom closeModal={closeModal} />
}
if (!canChangeDate) {
return <CannotChangeDate closeModal={closeModal} />
}
return <>{children}</>
}

View File

@@ -0,0 +1,64 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./priceAndDate.module.css"
interface PriceAndDateProps {
checkInDate: string
checkOutDate: string
label: string
price: string
striked?: boolean
}
export default function PriceAndDate({
checkInDate,
checkOutDate,
label,
price,
striked = false,
}: PriceAndDateProps) {
const intl = useIntl()
const checkInMsg = intl.formatMessage({
defaultMessage: "Check-in",
})
const checkOutMsg = intl.formatMessage({
defaultMessage: "Check-out",
})
return (
<div className={styles.container}>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.textDefault}>{label}</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.textSecondary}>{price}</p>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textSecondary}>{checkInMsg}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{striked ? <s>{checkInDate}</s> : checkInDate}
</p>
</Typography>
</div>
<div className={styles.item}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textSecondary}>{checkOutMsg}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{striked ? <s>{checkOutDate}</s> : checkOutDate}
</p>
</Typography>
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.item {
display: flex;
justify-content: space-between;
}
.textDefault {
color: var(--Text-Default);
}
.textSecondary {
color: var(--Text-Secondary);
}

View File

@@ -0,0 +1,11 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.dateComparison {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}

View File

@@ -0,0 +1,183 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import PriceContainer from "@/components/HotelReservation/MyStay/ReferenceCard/PriceContainer"
import Divider from "@/components/TempDesignSystem/Divider"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import PriceAndDate from "./PriceAndDate"
import styles from "./confirmation.module.css"
import type { Lang } from "@/constants/languages"
interface ConfirmationProps {
checkInDate: string
checkOutDate: string
closeModal: () => void
newPrice: string
}
function formatDate(date: Date | string, lang: Lang) {
return dt(date).locale(lang).format("dddd, DD MMM, YYYY")
}
export default function Confirmation({
checkInDate,
checkOutDate,
closeModal,
newPrice,
}: ConfirmationProps) {
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const { bookedRoom, oldPrice, totalAdults, totalChildren } = useMyStayStore(
(state) => ({
bookedRoom: state.bookedRoom,
oldPrice: state.totalPrice,
totalAdults: state.rooms.reduce(
(total, room) => total + (room.isCancelled ? 0 : room.adults),
0
),
totalChildren: state.rooms.reduce(
(total, room) =>
total + (room.isCancelled ? 0 : room.childrenInRoom.length),
0
),
})
)
const updateBooking = trpc.booking.update.useMutation({
onSuccess: (updatedBooking) => {
if (updatedBooking) {
utils.booking.get.invalidate({
confirmationNumber: updatedBooking.confirmationNumber,
})
toast.success(
intl.formatMessage({
defaultMessage: "Your stay was updated",
})
)
closeModal()
} else {
toast.error(
intl.formatMessage({
defaultMessage: "Failed to update your stay",
})
)
}
},
onError: () => {
toast.error(
intl.formatMessage({
defaultMessage: "Failed to update your stay",
})
)
},
})
function handleModifyStay() {
updateBooking.mutate({
confirmationNumber: bookedRoom.confirmationNumber,
checkInDate,
checkOutDate,
})
}
const originalCheckIn = formatDate(bookedRoom.checkInDate, lang)
const originalCheckOut = formatDate(bookedRoom.checkOutDate, lang)
const newCheckIn = formatDate(checkInDate, lang)
const newCheckOut = formatDate(checkOutDate, lang)
const nights = dt(newCheckOut)
.startOf("day")
.diff(dt(newCheckIn).startOf("day"), "days")
const nightsText = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
const newDatesLabel = intl.formatMessage({
defaultMessage: "New dates",
})
const oldDatesLabel = intl.formatMessage({
defaultMessage: "Old dates",
})
const title = intl.formatMessage({
defaultMessage: "Confirm date change",
})
const totalDueMsg = intl.formatMessage({
defaultMessage: "Total due",
})
const adultsText = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: totalAdults }
)
const childrenText = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: totalChildren }
)
return (
<Modal.Content>
<Modal.Content.Header handleClose={closeModal} title={title} />
<Modal.Content.Body>
<div className={styles.container}>
<div className={styles.dateComparison}>
<PriceAndDate
checkInDate={originalCheckIn}
checkOutDate={originalCheckOut}
label={oldDatesLabel}
price={oldPrice}
striked
/>
<Divider color="primaryLightSubtle" />
<PriceAndDate
checkInDate={newCheckIn}
checkOutDate={newCheckOut}
label={newDatesLabel}
price={newPrice}
/>
</div>
<PriceContainer
adultsText={adultsText}
childrenText={childrenText}
nightsText={nightsText}
price={newPrice}
text={totalDueMsg}
totalChildren={totalChildren}
/>
</div>
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary
disabled={updateBooking.isPending}
onClick={handleModifyStay}
>
{intl.formatMessage({ defaultMessage: "Confirm" })}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)
}

View File

@@ -0,0 +1,21 @@
"use client"
import { useIntl } from "react-intl"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function Error() {
const intl = useIntl()
return (
<Alert
type={AlertTypeEnum.Alarm}
heading={intl.formatMessage({
defaultMessage: "Error",
})}
text={intl.formatMessage({
defaultMessage: "Something went wrong!",
})}
/>
)
}

View File

@@ -0,0 +1,21 @@
"use client"
import { useIntl } from "react-intl"
import Alert from "@/components/TempDesignSystem/Alert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function NoAvailability() {
const intl = useIntl()
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={intl.formatMessage({
defaultMessage: "No single rooms are available on these dates",
})}
/>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
"use client"
import { useState } from "react"
import { createPortal } from "react-dom"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useMyStayStore } from "@/stores/my-stay"
import DatePickerSingleDesktop from "@/components/DatePicker/Single/Desktop"
import DatePickerSingleMobile from "@/components/DatePicker/Single/Mobile"
import Modal from "@/components/Modal"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import CalendarButton from "./CalendarButton"
import styles from "./newDates.module.css"
import type { DateRange } from "react-day-picker"
export default function NewDates() {
const { checkInDate, checkOutDate } = useMyStayStore((state) => ({
checkInDate: state.mainRoom.checkInDate,
checkOutDate: state.mainRoom.checkOutDate,
}))
const [showCheckInDatePicker, setShowCheckInDatePicker] = useState(false)
const [showCheckOutDatePicker, setShowCheckOutDatePicker] = useState(false)
const [selectedDates, setSelectedDates] = useState<DateRange>(() => ({
from: dt(checkInDate).startOf("day").toDate(),
to: dt(checkOutDate).startOf("day").toDate(),
}))
const intl = useIntl()
const lang = useLang()
const { setValue } = useFormContext()
// Calculate default number of days between check-in and check-out
const defaultDaysBetween = dt(checkOutDate)
.startOf("day")
.diff(dt(checkInDate).startOf("day"), "days")
function showCheckInPicker() {
// Update selected dates before showing picker
setSelectedDates((prev) => ({
from: prev.from ?? dt(checkInDate).startOf("day").toDate(),
to: prev.to ?? dt(checkOutDate).startOf("day").toDate(),
}))
setShowCheckInDatePicker(true)
setShowCheckOutDatePicker(false)
}
function showCheckOutPicker() {
// Update selected dates before showing picker
setSelectedDates((prev) => ({
from: prev.from ?? dt(checkInDate).startOf("day").toDate(),
to: prev.to ?? dt(checkOutDate).startOf("day").toDate(),
}))
setShowCheckOutDatePicker(true)
setShowCheckInDatePicker(false)
}
function handleCheckInDateSelect(date: Date) {
const newCheckIn = dt(date).startOf("day")
const currentCheckOut = dt(selectedDates.to).startOf("day")
// Calculate new check-out date based on defaultDaysBetween, only if new check-in is after current check-out
const newCheckOut = newCheckIn.isSameOrAfter(currentCheckOut)
? newCheckIn.add(defaultDaysBetween, "days")
: currentCheckOut
// Update selected dates state first
const newDates = {
from: newCheckIn.toDate(),
to: newCheckOut.toDate(),
}
setSelectedDates(newDates)
// Then update form values
setValue("checkInDate", newCheckIn.format("YYYY-MM-DD"))
setValue("checkOutDate", newCheckOut.format("YYYY-MM-DD"))
}
function handleCheckOutDateSelect(date: Date) {
const newCheckOut = dt(date).startOf("day")
const currentCheckIn = dt(selectedDates.from).startOf("day")
// Only adjust check-in if new check-out is before current check-in
const newCheckIn = newCheckOut.isBefore(currentCheckIn)
? newCheckOut.subtract(defaultDaysBetween, "days")
: currentCheckIn
// Update selected dates state
const newDates = {
from: newCheckIn.toDate(),
to: newCheckOut.toDate(),
}
setSelectedDates(newDates)
// Then update form values
setValue("checkInDate", newCheckIn.format("YYYY-MM-DD"))
setValue("checkOutDate", newCheckOut.format("YYYY-MM-DD"))
}
const fromDate = selectedDates.from ?? dt(checkInDate).toDate()
const toDate = selectedDates.to ?? dt(checkOutDate).toDate()
return (
<>
<div className={styles.container}>
<div className={styles.checkInDate}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({
defaultMessage: "Check-in",
})}
</Caption>
<CalendarButton
text={dt(selectedDates.from ?? new Date())
.locale(lang)
.format("dddd, DD MMM, YYYY")}
onClick={showCheckInPicker}
/>
</div>
<div className={styles.checkOutDate}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({
defaultMessage: "Check-out",
})}
</Caption>
<CalendarButton
text={dt(selectedDates.to ?? new Date())
.locale(lang)
.format("dddd, DD MMM, YYYY")}
onClick={showCheckOutPicker}
/>
</div>
</div>
{showCheckInDatePicker &&
createPortal(
<Modal
isOpen={showCheckInDatePicker}
onToggle={() => setShowCheckInDatePicker(!showCheckInDatePicker)}
>
<DatePickerSingleDesktop
close={() => setShowCheckInDatePicker(false)}
handleOnSelect={handleCheckInDateSelect}
selectedDate={fromDate}
startMonth={fromDate}
/>
<DatePickerSingleMobile
close={() => setShowCheckInDatePicker(false)}
handleOnSelect={handleCheckInDateSelect}
selectedDate={fromDate}
hideHeader
/>
</Modal>,
document.body
)}
{showCheckOutDatePicker &&
createPortal(
<Modal
isOpen={showCheckOutDatePicker}
onToggle={() => setShowCheckOutDatePicker(!showCheckOutDatePicker)}
>
<DatePickerSingleDesktop
close={() => setShowCheckOutDatePicker(false)}
handleOnSelect={handleCheckOutDateSelect}
selectedDate={toDate}
startMonth={toDate}
/>
<DatePickerSingleMobile
close={() => setShowCheckOutDatePicker(false)}
handleOnSelect={handleCheckOutDateSelect}
selectedDate={toDate}
hideHeader
/>
</Modal>,
document.body
)}
</>
)
}

View File

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

View File

@@ -0,0 +1,85 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import { toast } from "@/components/TempDesignSystem/Toasts"
import NoAvailability from "./Alerts/NoAvailability"
import NewDates from "./NewDates"
import {
type ChangeDatesFormProps,
type ChangeDatesSchema,
changeDatesSchema,
} from "@/types/components/hotelReservation/myStay/changeDates"
export default function Form({
checkAvailability,
closeModal,
noAvailability,
}: ChangeDatesFormProps) {
const intl = useIntl()
const { checkInDate, checkOutDate } = useMyStayStore((state) => ({
checkInDate: state.bookedRoom.checkInDate,
checkOutDate: state.bookedRoom.checkOutDate,
}))
const methods = useForm<ChangeDatesSchema>({
defaultValues: {
checkInDate: dt(checkInDate).format("YYYY-MM-DD"),
checkOutDate: dt(checkOutDate).format("YYYY-MM-DD"),
},
resolver: zodResolver(changeDatesSchema),
})
async function handleSubmit(values: ChangeDatesSchema) {
if (values.checkInDate && values.checkOutDate) {
await checkAvailability(values.checkInDate, values.checkOutDate)
} else {
toast.error(
intl.formatMessage({
defaultMessage: "Please select dates",
})
)
}
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit)}>
<Modal.Content>
<Modal.Content.Header
handleClose={closeModal}
title={intl.formatMessage({
defaultMessage: "New dates for the stay",
})}
/>
<Modal.Content.Body>
{noAvailability && <NoAvailability />}
<NewDates />
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={closeModal}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary
disabled={methods.formState.isSubmitting}
intent="secondary"
type="submit"
>
{intl.formatMessage({
defaultMessage: "Check availability",
})}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import { useSession } from "next-auth/react"
import { useState } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { useMyStayStore } from "@/stores/my-stay"
import { sumPackages } from "@/components/HotelReservation/utils"
import useLang from "@/hooks/useLang"
import { isValidClientSession } from "@/utils/clientSession"
import { formatPrice } from "@/utils/numberFormatting"
import Confirmation from "./Confirmation"
import Form from "./Form"
import type { ChangeDatesStepsProps } from "@/types/components/hotelReservation/myStay/changeDates"
import { CurrencyEnum } from "@/types/enums/currency"
interface Dates {
fromDate: string
toDate: string
}
export default function Steps({ closeModal }: ChangeDatesStepsProps) {
const { data: session } = useSession()
const isLoggedIn = isValidClientSession(session)
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const [dates, setDates] = useState<Dates | null>(null)
const [newPrice, setNewPrice] = useState<string | null>(null)
const [noAvailability, setNoAvailability] = useState(false)
const { breakfast, currencyCode, hotelId, packages, room } = useMyStayStore(
(state) => ({
breakfast: state.bookedRoom.breakfast,
currencyCode: state.bookedRoom.currencyCode,
hotelId: state.bookedRoom.hotelId,
packages: state.bookedRoom.packages ?? [],
room: {
adults: state.bookedRoom.adults,
bookingCode: state.bookedRoom.bookingCode ?? undefined,
childrenInRoom: state.bookedRoom.childrenInRoom,
rateCode: state.bookedRoom.rateDefinition.rateCode,
roomTypeCode: state.bookedRoom.roomTypeCode,
},
})
)
async function checkAvailability(fromDate: string, toDate: string) {
setNoAvailability(false)
const data = await utils.hotel.availability.myStay.fetch({
booking: { fromDate, hotelId, room, toDate },
lang,
})
if (!data || !data.selectedRoom || !data.selectedRoom.roomsLeft) {
setNoAvailability(true)
return
}
setDates({ fromDate, toDate })
const pkgsSum = sumPackages(packages)
const extraPrice = pkgsSum.price + (breakfast?.localPrice.totalPrice || 0)
if (isLoggedIn && "member" in data.product && data.product.member) {
const { currency, pricePerStay } = data.product.member.localPrice
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))
} else if ("public" in data.product && data.product.public) {
const { currency, pricePerStay } = data.product.public.localPrice
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))
} else if (
"corporateCheque" in data.product &&
data.product.corporateCheque.localPrice.additionalPricePerStay
) {
const { additionalPricePerStay, currency, numberOfCheques } =
data.product.corporateCheque.localPrice
setNewPrice(
formatPrice(
intl,
numberOfCheques,
CurrencyEnum.CC,
additionalPricePerStay + extraPrice,
currency?.toString() ?? pkgsSum.currency ?? currencyCode
)
)
} else if (
"redemption" in data.product &&
data.product.redemption.localPrice.additionalPricePerStay
) {
const { additionalPricePerStay, currency, pointsPerStay } =
data.product.redemption.localPrice
setNewPrice(
formatPrice(
intl,
pointsPerStay,
CurrencyEnum.POINTS,
additionalPricePerStay + extraPrice,
currency?.toString() ?? pkgsSum.currency ?? currencyCode
)
)
}
}
function goBackToSelectDates() {
setNewPrice(null)
setDates(null)
setNoAvailability(false)
}
const hasNewDate = newPrice && dates
const stepOne = !hasNewDate
const stepTwo = hasNewDate
return (
<>
{stepOne ? (
<Form
checkAvailability={checkAvailability}
closeModal={closeModal}
noAvailability={noAvailability}
/>
) : null}
{stepTwo ? (
<Confirmation
checkInDate={dates.fromDate}
checkOutDate={dates.toDate}
closeModal={goBackToSelectDates}
newPrice={newPrice}
/>
) : null}
</>
)
}

View File

@@ -0,0 +1,49 @@
"use client"
import { Dialog, DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import { dateHasPassed } from "../utils"
import Alerts from "./Alerts"
import Steps from "./Steps"
export default function ChangeDates() {
const intl = useIntl()
const { canChangeDate, checkInDate, checkInTime, isCancelled, priceType } =
useMyStayStore((state) => ({
canChangeDate: state.bookedRoom.canChangeDate,
checkInDate: state.bookedRoom.checkInDate,
checkInTime: state.hotel.hotelFacts.checkin.checkInTime,
isCancelled: state.bookedRoom.isCancelled,
priceType: state.bookedRoom.priceType,
}))
const isRewardNight = priceType === "points"
const isDisabled =
canChangeDate &&
!isCancelled &&
!isRewardNight &&
dateHasPassed(checkInDate, checkInTime)
const text = intl.formatMessage({ defaultMessage: "Change dates" })
return (
<DialogTrigger>
<Modal.Button icon="edit_calendar" isDisabled={isDisabled}>
{text}
</Modal.Button>
<Modal>
<Dialog>
{({ close }) => (
<Alerts closeModal={close}>
<Steps closeModal={close} />
</Alerts>
)}
</Dialog>
</Modal>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,18 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import CustomerSupportModal from "@/components/HotelReservation/MyStay/ReferenceCard/Actions/CustomerSupportModal"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
export default function CustomerSupport() {
const intl = useIntl()
return (
<DialogTrigger>
<Modal.Button icon="support_agent">
{intl.formatMessage({ defaultMessage: "Customer support" })}
</Modal.Button>
<CustomerSupportModal />
</DialogTrigger>
)
}

View File

@@ -0,0 +1,48 @@
.loading {
display: flex;
align-items: center;
justify-content: center;
width: 640px;
max-width: 100%;
height: 640px;
max-height: 100%;
}
.form {
display: grid;
gap: var(--Spacing-x3);
}
.termsAndConditions {
color: var(--Text-Secondary);
display: grid;
gap: var(--Spacing-x2);
}
.termsAndConditions .checkbox span {
align-items: flex-start;
}
.guaranteeCost {
align-items: center;
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
display: flex;
gap: var(--Spacing-x3);
justify-content: flex-end;
padding: var(--Spacing-x2);
}
.guaranteeCostText {
align-items: flex-end;
display: flex;
flex-direction: column;
}
.baseTextHighContrast {
color: var(--Base-Text-High-contrast);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,194 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentMethodEnum } from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { useMyStayStore } from "@/stores/my-stay"
import PaymentOptionsGroup from "@/components/HotelReservation/EnterDetails/Payment/PaymentOptionsGroup"
import MySavedCards from "@/components/HotelReservation/MySavedCards"
import PaymentOption from "@/components/HotelReservation/PaymentOption"
import LoadingSpinner from "@/components/LoadingSpinner"
import Divider from "@/components/TempDesignSystem/Divider"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
import { type GuaranteeFormData, paymentSchema } from "./schema"
import styles from "./form.module.css"
export default function Form() {
const intl = useIntl()
const lang = useLang()
const { confirmationNumber, currencyCode, hotelId, refId, savedCreditCards } =
useMyStayStore((state) => ({
confirmationNumber: state.bookedRoom.confirmationNumber,
currencyCode: state.bookedRoom.currencyCode,
hotelId: state.bookedRoom.hotelId,
refId: state.refId,
savedCreditCards: state.savedCreditCards,
}))
const methods = useForm<GuaranteeFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(confirmationNumber)
if (isLoading) {
return (
<div className={styles.loading}>
<LoadingSpinner />
</div>
)
}
function handleGuaranteeLateArrival(data: GuaranteeFormData) {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
trackGlaSaveCardAttempt(hotelId, savedCreditCard, "yes")
if (confirmationNumber) {
const card = savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
guaranteeBooking.mutate({
confirmationNumber,
language: lang,
...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`,
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}`,
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}`,
})
} else {
handleGuaranteeError("No confirmation number")
toast.error(
intl.formatMessage({
defaultMessage: "Something went wrong!",
})
)
}
}
const guaranteeMsg = intl.formatMessage(
{
defaultMessage:
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandic's Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={bookingTermsAndConditions[lang]}
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)
return (
<FormProvider {...methods}>
<form
className={styles.form}
id="guarantee"
onSubmit={methods.handleSubmit(handleGuaranteeLateArrival)}
>
{savedCreditCards?.length ? (
<MySavedCards savedCreditCards={savedCreditCards} />
) : null}
<PaymentOptionsGroup
name="paymentMethod"
label={
savedCreditCards?.length
? intl.formatMessage({
defaultMessage: "OTHER",
})
: undefined
}
>
<PaymentOption
value={PaymentMethodEnum.card}
label={intl.formatMessage({
defaultMessage: "Credit card",
})}
/>
</PaymentOptionsGroup>
<div className={styles.termsAndConditions}>
<Checkbox className={styles.checkbox} name="termsAndConditions">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{guaranteeMsg}</p>
</Typography>
</Checkbox>
</div>
<div className={styles.guaranteeCost}>
<div className={styles.guaranteeCostText}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.textDefault}>
{intl.formatMessage({
defaultMessage: "Total due",
})}
</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.textDefault}>
{intl.formatMessage({
defaultMessage:
"Your card will only be charged in the event of a no-show",
})}
</span>
</Typography>
</div>
<Divider variant="vertical" color="subtle" />
<Typography variant="Title/Subtitle/md">
<span className={styles.baseTextHighContrast}>
{formatPrice(intl, 0, currencyCode)}
</span>
</Typography>
</div>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const paymentSchema = z.object({
paymentMethod: z.string().nullable(),
termsAndConditions: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export interface GuaranteeFormData extends z.output<typeof paymentSchema> {}

View File

@@ -0,0 +1,69 @@
"use client"
import { Dialog, DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import { dateHasPassed } from "../utils"
import Form from "./Form"
export default function GuaranteeLateArrival() {
const intl = useIntl()
const { checkInDate, checkInTime, guaranteeInfo, isCancelled } =
useMyStayStore((state) => ({
checkInDate: state.bookedRoom.checkInDate,
checkInTime: state.hotel.hotelFacts.checkin.checkInTime,
guaranteeInfo: state.bookedRoom.guaranteeInfo,
isCancelled: state.bookedRoom.isCancelled,
}))
const guaranteeable =
!guaranteeInfo && !isCancelled && !dateHasPassed(checkInDate, checkInTime)
if (!guaranteeable) {
return null
}
const arriveLateMsg = intl.formatMessage({
defaultMessage:
"Planning to arrive after 18.00? Secure your room by guaranteeing it with a credit card. Without the guarantee and in case of no-show, the room might be reallocated after 18:00.",
})
const text = intl.formatMessage({
defaultMessage: "Guarantee late arrival",
})
return (
<DialogTrigger>
<Modal.Button icon="check">{text}</Modal.Button>
<Modal>
<Dialog>
{({ close }) => (
<Modal.Content>
<Modal.Content.Header handleClose={close} title={text}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{arriveLateMsg}</p>
</Typography>
</Modal.Content.Header>
<Modal.Content.Body>
<Form />
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={close}>
{intl.formatMessage({ defaultMessage: "Back" })}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary form="guarantee" type="submit">
{intl.formatMessage({ defaultMessage: "Guarantee" })}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)}
</Dialog>
</Modal>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { CancellationRuleEnum } from "@/constants/booking"
import { preliminaryReceipt } from "@/constants/routes/myStay"
import { useMyStayStore } from "@/stores/my-stay"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import { trackMyStayPageLink } from "@/utils/tracking"
import styles from "./view.module.css"
export default function ViewAndPrintReceipt() {
const intl = useIntl()
const lang = useLang()
const canDownloadInvoice = useMyStayStore(
(state) =>
!state.bookedRoom.isCancelled &&
!(
state.bookedRoom.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
)
)
if (!canDownloadInvoice) {
return null
}
function trackClick() {
trackMyStayPageLink("download invoice")
}
const printMsg = intl.formatMessage({
defaultMessage: "View and print receipt",
})
return (
<div onClickCapture={trackClick}>
<Link
className={styles.download}
href={preliminaryReceipt[lang]}
keepSearchParams
target="_blank"
>
<MaterialIcon color="Icon/Interactive/Default" icon="print" />
<Typography variant="Body/Paragraph/mdBold">
<span>{printMsg}</span>
</Typography>
</Link>
</div>
)
}

View File

@@ -0,0 +1,7 @@
.download {
align-items: center;
color: var(--Text-Interactive-Default);
display: flex;
gap: var(--Space-x1);
padding: var(--Space-x1) 0;
}

View File

@@ -0,0 +1,5 @@
.list {
list-style: none;
margin: 0;
padding: 0;
}

View File

@@ -0,0 +1,21 @@
import AddToCalendar from "./AddToCalendar"
import CancelStay from "./CancelStay"
import ChangeDates from "./ChangeDates"
import CustomerSupport from "./CustomerSupport"
import GuaranteeLateArrival from "./GuaranteeLateArrival"
import ViewAndPrintReceipt from "./ViewAndPrintReceipt"
import styles from "./actions.module.css"
export default function Actions() {
return (
<div className={styles.list}>
<ChangeDates />
<GuaranteeLateArrival />
<AddToCalendar />
<ViewAndPrintReceipt />
<CustomerSupport />
<CancelStay />
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { dt } from "@/lib/dt"
export function dateHasPassed(date: Date, time: string) {
const hour = dt(time, "HH:mm").hour()
const minute = dt(time, "HH:mm").minute()
return dt(date).hour(hour).minute(minute).isBefore(dt(), "minutes")
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import styles from "./info.module.css"
export default function Info() {
const intl = useIntl()
const text = intl.formatMessage({ defaultMessage: "Booking number" })
const { address, confirmationNumber, hotelName, phoneNumber } =
useMyStayStore((state) => ({
address: state.hotel.address,
confirmationNumber: state.bookedRoom.confirmationNumber,
hotelName: state.hotel.name,
phoneNumber: state.hotel.contactInformation.phoneNumber,
}))
return (
<div className={styles.container}>
<div className={styles.booking}>
<Typography variant="Tag/sm">
<span className={styles.text}>{text}</span>
</Typography>
<Typography variant="Title/sm">
<span className={styles.confirmationNumber}>
{confirmationNumber}
</span>
</Typography>
</div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.address}>
<span>{hotelName}</span>
<span>{address.streetAddress}</span>
<span>{address.city}</span>
<span>{phoneNumber}</span>
</div>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,29 @@
.container {
align-items: flex-start;
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-radius-md);
display: flex;
flex-direction: column;
gap: var(--Space-x2);
justify-content: center;
padding: var(--Space-x15) var(--Space-x3);
}
.booking {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
}
.text {
color: var(--Text-Default);
}
.confirmationNumber {
color: var(--Text-Heading);
}
.address {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,65 @@
"use client"
import {
Button as ButtonRAC,
Dialog,
DialogTrigger,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/ReferenceCard/Modal"
import Actions from "./Actions"
import Info from "./Info"
import styles from "./manageStay.module.css"
export default function ManageStay() {
const intl = useIntl()
const allRoomsAreCancelled = useMyStayStore(
(state) => state.allRoomsAreCancelled
)
const color = allRoomsAreCancelled
? "Icon/Interactive/Disabled"
: "Icon/Inverted"
const manageStay = intl.formatMessage({
defaultMessage: "Manage stay",
})
return (
<DialogTrigger>
<ButtonRAC className={styles.trigger} isDisabled={allRoomsAreCancelled}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{manageStay}</span>
</Typography>
<MaterialIcon color={color} icon="keyboard_arrow_down" />
</ButtonRAC>
<Modal>
<Dialog className={styles.dialog}>
{({ close }) => (
<>
<header className={styles.header}>
<Typography variant="Title/Subtitle/lg">
<span className={styles.title}>{manageStay}</span>
</Typography>
<ButtonRAC className={styles.close} onPress={close}>
<MaterialIcon color="Icon/Feedback/Neutral" icon="close" />
</ButtonRAC>
</header>
<div className={styles.content}>
<Actions />
<Info />
</div>
</>
)}
</Dialog>
</Modal>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,52 @@
.trigger {
align-items: center;
background-color: var(--Component-Button-Brand-Tertiary-Fill-Default);
border: 2px solid var(--Component-Button-Brand-Tertiary-Border-Default);
border-radius: var(--Corner-radius-rounded);
color: var(--Text-Inverted);
cursor: pointer;
display: flex;
gap: var(--Space-x1);
height: 48px;
justify-content: center;
padding: var(--Space-x2) var(--Space-x4);
transition: background-color 200ms ease;
&:hover {
background-color: var(--Component-Button-Brand-Tertiary-Fill-Hover);
}
&:disabled {
background-color: var(--Component-Button-Brand-Tertiary-Fill-Disabled);
cursor: not-allowed;
}
}
.dialog {
display: grid;
gap: var(--Space-x3);
}
.header {
align-items: center;
display: flex;
gap: var(--Space-x2);
justify-content: space-between;
}
.title {
color: var(--Text-Default);
}
.close {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.content {
display: grid;
gap: var(--Space-x3);
grid-template-columns: 1fr 1fr;
}

View File

@@ -0,0 +1,34 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import ManageStay from "./ManageStay"
import styles from "./notCancelled.module.css"
export default function NotCancelled() {
const intl = useIntl()
const location = useMyStayStore((state) => state.hotel.location)
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${location.latitude},${location.longitude}`
return (
<>
<ManageStay />
<Link className={styles.link} href={directionsUrl} target="_blank">
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{intl.formatMessage({
defaultMessage: "Find us",
})}
</span>
</Typography>
<MaterialIcon color="Icon/Interactive/Default" icon="location_on" />
</Link>
</>
)
}

View File

@@ -0,0 +1,9 @@
.link {
align-items: center;
border: 2px solid var(--Component-Button-Brand-Secondary-Border-Default);
border-radius: var(--Corner-radius-rounded);
color: var(--Text-Interactive-Default);
display: flex;
justify-content: center;
text-decoration: none;
}

View File

@@ -0,0 +1,12 @@
.actionArea {
display: grid;
gap: var(--Spacing-x2);
}
@media (min-width: 768px) {
.actionArea {
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
padding-top: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,16 @@
"use client"
import { useMyStayStore } from "@/stores/my-stay"
import Cancelled from "./Cancelled"
import NotCancelled from "./NotCancelled"
import styles from "./actions.module.css"
export default function Actions() {
const isCancelled = useMyStayStore((state) => state.bookedRoom.isCancelled)
return (
<div className={styles.actionArea}>
{isCancelled ? <Cancelled /> : <NotCancelled />}
</div>
)
}

View File

@@ -0,0 +1,5 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,41 @@
"use client"
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import IconChip from "@/components/TempDesignSystem/IconChip"
import styles from "./bookingCode.module.css"
export default function BookingCode() {
const intl = useIntl()
const bookingCode = useMyStayStore((state) => state.bookedRoom.bookingCode)
if (!bookingCode) {
return null
}
return (
<div className={styles.row}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Booking code",
})}
</p>
</Typography>
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>{bookingCode}</span>
</Typography>
</IconChip>
</div>
)
}

View File

@@ -0,0 +1,16 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}
.label {
align-items: center;
display: flex;
gap: var(--Space-x1);
}
.row .textDefault {
color: var(--Text-Default);
text-transform: capitalize;
}

View File

@@ -0,0 +1,43 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import styles from "./cancellations.module.css"
export default function Cancellations() {
const intl = useIntl()
const rooms = useMyStayStore((state) => state.rooms)
const cancelledRooms = rooms.filter((r) => r.isCancelled).length
if (!cancelledRooms) {
return null
}
const totalRoomsMsg = intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms: cancelledRooms }
)
return (
<div className={styles.row}>
<div className={styles.label}>
<MaterialIcon icon="cancel" />
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{intl.formatMessage({ defaultMessage: "Cancellations" })}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>{totalRoomsMsg}</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}
.label {
align-items: center;
display: flex;
gap: var(--Space-x1);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,53 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import { useMyStayStore } from "@/stores/my-stay"
import useLang from "@/hooks/useLang"
import styles from "./dates.module.css"
export default function Dates() {
const intl = useIntl()
const lang = useLang()
const { checkInDate, checkOutDate } = useMyStayStore((state) => ({
checkInDate: state.bookedRoom.checkInDate,
checkOutDate: state.bookedRoom.checkOutDate,
}))
const from = dt(checkInDate).locale(lang).format("D MMM")
const fromYear = dt(checkInDate).year()
const to = dt(checkOutDate).locale(lang).format("D MMM")
const toYear = dt(checkOutDate).year()
const isSameYear = fromYear === toYear
const stayFrom = isSameYear ? from : `${from}, ${fromYear}`
const stayTo = `${to}, ${toYear}`
return (
<div className={styles.row}>
<div className={styles.label}>
<MaterialIcon icon="calendar_month" />
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{intl.formatMessage({
defaultMessage: "Dates",
})}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
{stayFrom} {stayTo}
</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}
.label {
align-items: center;
display: flex;
gap: var(--Space-x1);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -0,0 +1,43 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import styles from "./guaranteeInfo.module.css"
export default function GuaranteeInfo() {
const intl = useIntl()
const { allRoomsAreCancelled, guaranteeInfo } = useMyStayStore((state) => ({
allRoomsAreCancelled: state.allRoomsAreCancelled,
guaranteeInfo: state.bookedRoom.guaranteeInfo,
}))
if (allRoomsAreCancelled || !guaranteeInfo) {
return null
}
return (
<div className={styles.row}>
<div className={styles.label}>
<MaterialIcon icon="check_circle" />
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{intl.formatMessage({
defaultMessage: "Late arrival",
})}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
defaultMessage: "Check-in after 18:00",
})}
</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,20 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}
.label {
align-items: center;
display: flex;
gap: var(--Space-x1);
}
.textDefault {
color: var(--Text-Default);
}
.row p.guests {
color: var(--Text-Default);
text-transform: capitalize;
}

View File

@@ -0,0 +1,61 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import styles from "./guests.module.css"
export default function Guests() {
const intl = useIntl()
const rooms = useMyStayStore((state) => state.rooms)
const adults = rooms.reduce((acc, room) => acc + room.adults, 0)
const children = rooms.reduce(
(acc, room) => acc + (room.childrenAges?.length ?? 0),
0
)
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{ adults }
)
const childrenMsg = intl.formatMessage(
{
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{ children }
)
const adultsOnlyMsg = adultsMsg
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(" · ")
let guests = ""
if (children > 0) {
guests = adultsAndChildrenMsg
} else {
guests = adultsOnlyMsg
}
return (
<div className={styles.row}>
<div className={styles.label}>
<MaterialIcon icon="person" />
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{intl.formatMessage({ defaultMessage: "Guests" })}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.guests}>{guests}</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,14 @@
.button {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
gap: var(--Space-x1);
padding: var(--Space-x1) 0;
width: 100%;
}
.text {
color: var(--Text-Interactive-Default);
}

View File

@@ -0,0 +1,31 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import {
MaterialIcon
,type
MaterialIconProps} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./button.module.css"
interface ButtonProps extends React.PropsWithChildren {
icon: MaterialIconProps["icon"]
isDisabled?: boolean
}
export default function Button({
children,
icon,
isDisabled = false,
}: ButtonProps) {
return (
<ButtonRAC className={styles.button} isDisabled={isDisabled}>
<MaterialIcon color="Icon/Interactive/Default" icon={icon} />
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.text}>{children}</span>
</Typography>
</ButtonRAC>
)
}

View File

@@ -0,0 +1,15 @@
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
max-height: 70vh;
overflow-y: auto;
width: 100%;
}
@media screen and (min-width: 768px) {
.content {
width: 640px;
max-width: 100%;
}
}

View File

@@ -0,0 +1,5 @@
import styles from "./body.module.css"
export default function Body({ children }: React.PropsWithChildren) {
return <div className={styles.content}>{children}</div>
}

View File

@@ -0,0 +1,7 @@
.footer {
border-top: 1px solid var(--Base-Border-Subtle);
display: flex;
justify-content: space-between;
padding-top: var(--Spacing-x3);
width: 100%;
}

View File

@@ -0,0 +1,64 @@
import Button from "@/components/TempDesignSystem/Button"
import styles from "./footer.module.css"
import type { ButtonHTMLAttributes, PropsWithChildren } from "react"
import type { ButtonProps as ReactAriaButtonProps } from "react-aria-components"
import type { ButtonProps as _ButtonProps } from "@/components/TempDesignSystem/Button/button"
export default function Footer({ children }: PropsWithChildren) {
return <footer className={styles.footer}>{children}</footer>
}
interface ButtonProps extends PropsWithChildren {
intent?: _ButtonProps["intent"]
onClick?: ReactAriaButtonProps["onPress"]
type?: ButtonHTMLAttributes<HTMLButtonElement>["type"]
}
interface PrimaryButtonProps extends ButtonProps {
disabled?: boolean
form?: string
}
Footer.Primary = function PrimaryButton({
children,
disabled = false,
form,
intent = "primary",
onClick,
type = "button",
}: PrimaryButtonProps) {
return (
<Button
disabled={disabled}
form={form}
intent={intent}
onClick={onClick}
theme="base"
type={type}
>
{children}
</Button>
)
}
Footer.Secondary = function SecondaryButton({
children,
intent = "text",
onClick,
type = "button",
}: ButtonProps) {
return (
<Button
color="burgundy"
intent={intent}
onClick={onClick}
theme="base"
type={type}
>
{children}
</Button>
)
}

View File

@@ -0,0 +1,15 @@
.header {
display: grid;
gap: var(--Space-x05) var(--Space-x2);
grid-template-columns: 1fr auto;
}
.close {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
padding: 0;
}

View File

@@ -0,0 +1,24 @@
import { Button as ButtonRAC } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./header.module.css"
interface HeaderProps extends React.PropsWithChildren {
handleClose: () => void
title: string
}
export default function Header({ children, handleClose, title }: HeaderProps) {
return (
<header className={styles.header}>
<Subtitle>{title}</Subtitle>
<ButtonRAC className={styles.close} onPress={handleClose}>
<MaterialIcon icon="close" color="Icon/Interactive/Placeholder" />
</ButtonRAC>
{children}
</header>
)
}

View File

@@ -0,0 +1,15 @@
import Body from "./Body"
import Footer from "./Footer"
import Header from "./Header"
import styles from "./modalContent.module.css"
import type { PropsWithChildren } from "react"
export default function ModalContent({ children }: PropsWithChildren) {
return <div className={styles.container}>{children}</div>
}
ModalContent.Body = Body
ModalContent.Footer = Footer
ModalContent.Header = Header

View File

@@ -0,0 +1,4 @@
.container {
display: grid;
gap: var(--Space-x3);
}

View File

@@ -0,0 +1,17 @@
import { Modal as ModalRAC, ModalOverlay } from "react-aria-components"
import Button from "./Button"
import ModalContent from "./ModalContent"
import styles from "./modal.module.css"
export default function Modal({ children }: React.PropsWithChildren) {
return (
<ModalOverlay className={styles.overlay} isDismissable>
<ModalRAC className={styles.modal}>{children}</ModalRAC>
</ModalOverlay>
)
}
Modal.Button = Button
Modal.Content = ModalContent

View File

@@ -0,0 +1,70 @@
.overlay {
background: rgba(0, 0, 0, 0.4);
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
width: 100dvw;
z-index: var(--default-modal-overlay-z-index);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: var(--UI-Input-Controls-Surface-Normal);
border-top-left-radius: var(--Corner-radius-Large);
border-top-right-radius: var(--Corner-radius-Large);
max-height: 95dvh;
overflow-y: auto;
padding: var(--Space-x3);
position: absolute;
z-index: var(--default-modal-z-index);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media screen and (min-width: 768px) {
.overlay {
align-items: center;
display: flex;
justify-content: center;
}
.modal {
border-radius: var(--Corner-radius-Large);
width: min(690px, 100dvw);
}
}

View File

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

View File

@@ -0,0 +1,22 @@
.priceContainer {
display: flex;
padding: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
align-items: center;
justify-content: flex-end;
}
.info {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x2);
text-align: right;
display: flex;
flex-direction: column;
}
.price {
padding-left: var(--Spacing-x2);
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Divider from "@/components/TempDesignSystem/Divider"
import styles from "./reference.module.css"
export default function Reference() {
const intl = useIntl()
const { cancellationNumber, confirmationNumber, isCancelled, rooms } =
useMyStayStore((state) => ({
cancellationNumber: state.bookedRoom.cancellationNumber,
confirmationNumber: state.bookedRoom.confirmationNumber,
isCancelled: state.bookedRoom.isCancelled,
rooms: state.rooms,
}))
if (rooms.length > 1) {
return null
}
const title = isCancelled
? intl.formatMessage({
defaultMessage: "Cancellation number",
})
: intl.formatMessage({
defaultMessage: "Booking number",
})
return (
<>
<div className={styles.row}>
<Typography variant="Body/Lead text">
<p className={styles.textDefault}>{title}</p>
</Typography>
<Typography variant="Title/Subtitle/md">
<p className={styles.textDefault}>
{isCancelled ? <s>{cancellationNumber}</s> : confirmationNumber}
</p>
</Typography>
</div>
<Divider color="subtle" />
</>
)
}

View File

@@ -0,0 +1,10 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
padding-bottom: var(--Space-x1);
}
.textDefault {
color: var(--Text-Default);
}

View File

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

@@ -0,0 +1,43 @@
"use client"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import styles from "./room.module.css"
export default function Room() {
const intl = useIntl()
const { bookedRoom, rooms } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
rooms: state.rooms,
}))
const roomMsg = intl.formatMessage({
defaultMessage: "Room",
})
const roomsMsg = intl.formatMessage({
defaultMessage: "Rooms",
})
const room =
rooms.length > 1 ? `${rooms.length} ${roomsMsg}` : bookedRoom.roomName
const title = rooms.length > 1 ? roomsMsg : roomMsg
return (
<div className={styles.row}>
<div className={styles.label}>
<MaterialIcon icon="bed" />
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>{title}</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>{room}</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.row {
align-items: center;
display: flex;
justify-content: space-between;
}
.label {
align-items: center;
display: flex;
gap: var(--Space-x1);
}
.textDefault {
color: var(--Text-Default);
}

View File

@@ -1,397 +1,48 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
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 Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useGuaranteePaymentFailedToast } from "@/hooks/booking/useGuaranteePaymentFailedToast"
import useLang from "@/hooks/useLang"
import ManageStay from "../ManageStay"
import TotalPrice from "../Rooms/TotalPrice"
import { mapRoomDetails } from "../utils/mapRoomDetails"
import ReferenceCardSkeleton from "./ReferenceCardSkeleton"
import Actions from "./Actions"
import BookingCode from "./BookingCode"
import Cancellations from "./Cancellations"
import Dates from "./Dates"
import GuaranteeInfo from "./GuaranteeInfo"
import Guests from "./Guests"
import Reference from "./Reference"
import Room from "./Room"
import styles from "./referenceCard.module.css"
import type { Hotel, Room } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user"
interface ReferenceCardProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
room:
| (Room & {
bedType: Room["roomTypes"][number]
})
| null
savedCreditCards: CreditCard[] | null
refId: string
isLoggedIn: boolean
}
export function ReferenceCard({
booking,
hotel,
room,
savedCreditCards,
refId,
isLoggedIn,
}: ReferenceCardProps) {
export function ReferenceCard() {
const intl = useIntl()
const lang = useLang()
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
)
// 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,
roomPoints: booking.roomPoints,
})
addBookedRoom(
mapRoomDetails({
booking,
room,
roomNumber: 1,
})
)
}, [booking, room, addBookedRoom, addRoomPrice])
useGuaranteePaymentFailedToast()
if (!bookedRoom.roomNumber) return <ReferenceCardSkeleton />
const {
confirmationNumber,
cancellationNumber,
checkInDate,
checkOutDate,
isCancelled,
bookingCode,
rateDefinition,
priceType,
} = bookedRoom
const isMultiRoom = bookedRoom.linkedReservations.length > 0
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const allRooms = [bookedRoom, ...linkedReservationRooms]
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(
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{
adults: adults,
}
)
const childrenMsg = intl.formatMessage(
{
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{
children: children,
}
)
const cancelledRoomsMsg = intl.formatMessage(
{
defaultMessage: "{rooms, plural, one {# room} other {# rooms}}",
},
{
rooms: cancelledRooms,
}
)
const roomCancelledRoomsMsg = intl.formatMessage({
defaultMessage: "Room cancelled",
})
const roomsMsg = intl.formatMessage(
{
defaultMessage: "{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}>
{!isMultiRoom && (
<>
<div className={styles.referenceRow}>
<Subtitle color="uiTextHighContrast" className={styles.titleMobile}>
{intl.formatMessage({
defaultMessage: "Reference",
})}
</Subtitle>
<Subtitle
color="uiTextHighContrast"
className={styles.titleDesktop}
>
{isCancelled && !isMultiRoom
? intl.formatMessage({
defaultMessage: "Cancellation number",
})
: intl.formatMessage({
defaultMessage: "Reference number",
})}
</Subtitle>
<Subtitle color="uiTextHighContrast">
{isCancelled && !isMultiRoom
? cancellationNumber
: confirmationNumber}
</Subtitle>
</div>
<Reference />
<Dates />
<Guests />
<Room />
<Cancellations />
<GuaranteeInfo />
<Divider color="subtle" />
<Divider color="subtle" className={styles.divider} />
</>
)}
{!allRoomsCancelled && (
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
defaultMessage: "Guests",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>
{allRooms.length > 1
? children > 0
? adultsAndChildrenAndRoomsMsg
: adultsAndRoomsMsg
: children > 0
? adultsAndChildrenMsg
: adultsOnlyMsg}
</p>
</Typography>
</div>
)}
{allRooms.some((room) => room.isCancelled) && (
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
defaultMessage: "Cancellation",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.cancelledRooms}>
{isMultiRoom
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${cancelledRoomsMsg} ${intl.formatMessage({
defaultMessage: "cancelled",
})}`
: roomCancelledRoomsMsg}
</p>
</Typography>
</div>
)}
{!allRoomsCancelled && (
<>
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
defaultMessage: "Check-in",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${dt(checkInDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage(
{
defaultMessage: "from",
}
)} ${hotel.hotelFacts.checkin.checkInTime}`}
</p>
</Typography>
</div>
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
defaultMessage: "Check-out",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${dt(checkOutDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage(
{
defaultMessage: "until",
}
)} ${hotel.hotelFacts.checkin.checkOutTime}`}
</p>
</Typography>
</div>
</>
)}
<Divider color="subtle" className={styles.divider} />
{booking.guaranteeInfo && !allRoomsCancelled && (
<>
<div className={styles.guaranteed}>
<MaterialIcon
icon="check_circle"
color="Icon/Feedback/Success"
size={20}
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.guaranteedText}>
<strong>
{intl.formatMessage({
defaultMessage: "Booking guaranteed.",
})}
</strong>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}{" "}
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
{intl.formatMessage({
defaultMessage:
"Your stay remains available for check-in after 18:00.",
})}
</p>
</Typography>
</div>
<Divider color="subtle" className={styles.divider} />
</>
)}
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<div className={styles.row}>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage: "Total",
})}
</p>
</Typography>
<TotalPrice variant="Title/Subtitle/md" type={priceType} />
<TotalPrice />
</div>
{bookingCode && (
<div className={styles.referenceRow}>
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
defaultMessage: "Booking code",
})}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
{intl.formatMessage(
{
defaultMessage: "<strong>Booking code</strong>: {value}",
},
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
</div>
)}
<div className={styles.actionArea}>
<ManageStay
hotel={hotel}
savedCreditCards={savedCreditCards}
refId={refId}
isLoggedIn={isLoggedIn}
/>
<Button fullWidth intent="secondary" asChild size="small">
<Link href={directionsUrl} target="_blank">
{intl.formatMessage({
defaultMessage: "Get directions",
})}
</Link>
</Button>
</div>
{isMultiRoom && (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.note}>
{intl.formatMessage({
defaultMessage: "Multi-room stay",
})}
</p>
</Typography>
)}
<Typography variant="Body/Supporting text (caption)/smRegular">
<p
className={`${styles.note} ${allRoomsCancelled ? styles.cancelledNote : ""}`}
>
{rateDefinition.generalTerms.map((term) => (
<span key={term}>
{term}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{term.endsWith(".") ? " " : ". "}
</span>
))}
</p>
</Typography>
<BookingCode />
<Actions />
</div>
)
}

View File

@@ -1,72 +1,19 @@
.referenceCard {
width: var(--max-width-content);
max-width: 588px;
margin: 0 auto;
padding: var(--Spacing-x3);
border-radius: var(--Corner-radius-Large);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Large);
box-shadow: var(--popup-box-shadow);
display: flex;
flex-direction: column;
gap: var(--Space-x1);
margin: 0 auto;
max-width: 588px;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4);
width: var(--max-width-content);
}
.referenceRow {
.row {
align-items: center;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--Spacing-x-one-and-half);
}
.divider {
margin-bottom: var(--Spacing-x-one-and-half);
}
.cancelledRooms {
color: var(--Scandic-Brand-Scandic-Red);
}
.actionArea {
display: flex;
gap: var(--Spacing-x2);
margin: var(--Spacing-x4) 0 var(--Spacing-x3);
}
.note {
text-align: center;
width: 80%;
margin: 0 auto;
}
.cancelledNote {
color: var(--UI-Text-Placeholder);
}
.titleDesktop {
display: none;
}
.guaranteed {
align-items: flex-start;
border-radius: var(--Corner-radius-Medium);
display: flex;
background-color: var(--Surface-Feedback-Succes);
gap: var(--Spacing-x1);
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;
}
padding-top: var(--Space-x1);
}