feat: refactor of my stay
This commit is contained in:
committed by
Simon.Emanuelsson
parent
b5deb84b33
commit
ec087a3d15
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x5);
|
||||
}
|
||||
|
||||
.textDefault {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -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 doesn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.toastContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.textDefault {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -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: "We’re sorry that things didn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.dateComparison {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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!",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.download {
|
||||
align-items: center;
|
||||
color: var(--Text-Interactive-Default);
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
padding: var(--Space-x1) 0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./body.module.css"
|
||||
|
||||
export default function Body({ children }: React.PropsWithChildren) {
|
||||
return <div className={styles.content}>{children}</div>
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.textDefault {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user