feat: refactor of my stay
This commit is contained in:
committed by
Simon.Emanuelsson
parent
b5deb84b33
commit
ec087a3d15
@@ -8,7 +8,7 @@ import { myStay } from "@/constants/routes/myStay"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import GuaranteeCallback from "@/components/HotelReservation/MyStay/Ancillaries/GuaranteeCallback"
|
||||
import TrackGuarantee from "@/components/HotelReservation/MyStay/GuaranteeLateArrival/GuaranteeLateArrivalCallback"
|
||||
import TrackGuarantee from "@/components/HotelReservation/MyStay/TrackGuarantee"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default function HotelReservationLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<SidePeek />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
}
|
||||
|
||||
.blurOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 80px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.logIn {
|
||||
padding: var(--Spacing-x9) var(--Spacing-x2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
color: var(--Scandic-Grey-100);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,234 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { MyStay } from "@/components/HotelReservation/MyStay"
|
||||
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getAncillaryPackages,
|
||||
getBookingConfirmation,
|
||||
getLinkedReservations,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSavedPaymentCardsSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { decrypt } from "@/server/routers/utils/encryption"
|
||||
|
||||
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_UNAUTHORIZED,
|
||||
} from "@/components/HotelReservation/MyStay/accessBooking"
|
||||
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
|
||||
import BookingSummary from "@/components/HotelReservation/MyStay/BookingSummary"
|
||||
import { Header } from "@/components/HotelReservation/MyStay/Header"
|
||||
import Promo from "@/components/HotelReservation/MyStay/Promo"
|
||||
import { ReferenceCard } from "@/components/HotelReservation/MyStay/ReferenceCard"
|
||||
import Rooms from "@/components/HotelReservation/MyStay/Rooms"
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
import Image from "@/components/Image"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import MyStayProvider from "@/providers/MyStay"
|
||||
import { getCurrentWebUrl } from "@/utils/url"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default function MyStayPage({
|
||||
export default async function MyStay({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { RefId?: string }>) {
|
||||
if (!searchParams.RefId) {
|
||||
setLang(params.lang)
|
||||
const refId = searchParams.RefId
|
||||
|
||||
if (!refId) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<MyStaySkeleton />}>
|
||||
<MyStay refId={searchParams.RefId} />
|
||||
</Suspense>
|
||||
)
|
||||
const value = decrypt(refId)
|
||||
if (!value) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const [confirmationNumber, lastName] = value.split(",")
|
||||
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
|
||||
if (!bookingConfirmation) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
|
||||
|
||||
const user = await getProfileSafely()
|
||||
const bv = cookies().get("bv")?.value
|
||||
const intl = await getIntl()
|
||||
|
||||
const access = accessBooking(booking.guest, lastName, user, bv)
|
||||
|
||||
if (access === ACCESS_GRANTED) {
|
||||
const lang = params.lang
|
||||
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
|
||||
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
|
||||
|
||||
const linkedReservationsPromise = getLinkedReservations({
|
||||
rooms: booking.linkedReservations,
|
||||
})
|
||||
|
||||
const packagesInput = {
|
||||
adults: booking.adults,
|
||||
children: booking.childrenAges.length,
|
||||
endDate: toDate,
|
||||
hotelId: hotel.operaId,
|
||||
lang,
|
||||
startDate: fromDate,
|
||||
packageCodes: [
|
||||
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
|
||||
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
|
||||
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
|
||||
],
|
||||
}
|
||||
const supportedCards = hotel.merchantInformationData.cards
|
||||
const savedPaymentCardsInput = { supportedCards }
|
||||
|
||||
const hasBreakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
|
||||
const shouldFetchBreakfastPackages =
|
||||
!hasBreakfastPackage && !breakfastIncluded
|
||||
if (shouldFetchBreakfastPackages) {
|
||||
void getPackages(packagesInput)
|
||||
}
|
||||
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
|
||||
|
||||
const ancillaryPackages = await getAncillaryPackages({
|
||||
fromDate,
|
||||
hotelId: hotel.operaId,
|
||||
toDate,
|
||||
})
|
||||
|
||||
let breakfastPackages = null
|
||||
if (shouldFetchBreakfastPackages) {
|
||||
breakfastPackages = await getPackages(packagesInput)
|
||||
}
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely(
|
||||
savedPaymentCardsInput
|
||||
)
|
||||
|
||||
const imageSrc =
|
||||
hotel.hotelContent.images.imageSizes.large ??
|
||||
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
|
||||
hotel.galleryImages[0]?.imageSizes.large
|
||||
|
||||
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
|
||||
const promoUrl = env.HIDE_FOR_NEXT_RELEASE
|
||||
? new URL(getCurrentWebUrl({ path: "/", lang }))
|
||||
: new URL(`${baseUrl}/${lang}/`)
|
||||
|
||||
promoUrl.searchParams.set("hotel", hotel.operaId)
|
||||
|
||||
return (
|
||||
<MyStayProvider
|
||||
bookingConfirmation={bookingConfirmation}
|
||||
breakfastPackages={breakfastPackages}
|
||||
lang={params.lang}
|
||||
linkedReservationsPromise={linkedReservationsPromise}
|
||||
refId={refId}
|
||||
roomCategories={roomCategories}
|
||||
savedCreditCards={savedCreditCards}
|
||||
>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.blurOverlay} />
|
||||
{imageSrc && (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={imageSrc}
|
||||
alt={hotel.name}
|
||||
fill
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Header cityName={hotel.cityName} name={hotel.name} />
|
||||
<ReferenceCard />
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
packages={breakfastPackages}
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Rooms user={user} />
|
||||
|
||||
<BookingSummary hotel={hotel} />
|
||||
<Promo
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Book your next stay",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
buttonText={intl.formatMessage({
|
||||
defaultMessage: "Explore Scandic hotels",
|
||||
})}
|
||||
href={promoUrl.toString()}
|
||||
image={hotel.hotelContent.images}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SidePeek />
|
||||
</MyStayProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (access === ERROR_BAD_REQUEST) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.form}>
|
||||
<AdditionalInfoForm
|
||||
confirmationNumber={confirmationNumber}
|
||||
lastName={lastName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (access === ERROR_UNAUTHORIZED) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.logIn}>
|
||||
<Typography variant="Title/md">
|
||||
<h1>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "You need to be logged in to view your booking",
|
||||
})}
|
||||
</h1>
|
||||
</Typography>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"And you need to be logged in with the same member account that made the booking.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return notFound()
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default function HotelReservationLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SidePeek />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
}
|
||||
|
||||
.blurOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 80px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.logIn {
|
||||
padding: var(--Spacing-x9) var(--Spacing-x2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
color: var(--Scandic-Grey-100);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,234 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { MyStay } from "@/components/HotelReservation/MyStay"
|
||||
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getAncillaryPackages,
|
||||
getBookingConfirmation,
|
||||
getLinkedReservations,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSavedPaymentCardsSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { decrypt } from "@/server/routers/utils/encryption"
|
||||
|
||||
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_UNAUTHORIZED,
|
||||
} from "@/components/HotelReservation/MyStay/accessBooking"
|
||||
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
|
||||
import BookingSummary from "@/components/HotelReservation/MyStay/BookingSummary"
|
||||
import { Header } from "@/components/HotelReservation/MyStay/Header"
|
||||
import Promo from "@/components/HotelReservation/MyStay/Promo"
|
||||
import { ReferenceCard } from "@/components/HotelReservation/MyStay/ReferenceCard"
|
||||
import Rooms from "@/components/HotelReservation/MyStay/Rooms"
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
import Image from "@/components/Image"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import MyStayProvider from "@/providers/MyStay"
|
||||
import { getCurrentWebUrl } from "@/utils/url"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default function MyStayPage({
|
||||
export default async function MyStay({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { RefId?: string }>) {
|
||||
if (!searchParams.RefId) {
|
||||
setLang(params.lang)
|
||||
const refId = searchParams.RefId
|
||||
|
||||
if (!refId) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<MyStaySkeleton />}>
|
||||
<MyStay refId={searchParams.RefId} />
|
||||
</Suspense>
|
||||
)
|
||||
const value = decrypt(refId)
|
||||
if (!value) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const [confirmationNumber, lastName] = value.split(",")
|
||||
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
|
||||
if (!bookingConfirmation) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
|
||||
|
||||
const user = await getProfileSafely()
|
||||
const bv = cookies().get("bv")?.value
|
||||
const intl = await getIntl()
|
||||
|
||||
const access = accessBooking(booking.guest, lastName, user, bv)
|
||||
|
||||
if (access === ACCESS_GRANTED) {
|
||||
const lang = params.lang
|
||||
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
|
||||
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
|
||||
|
||||
const linkedReservationsPromise = getLinkedReservations({
|
||||
rooms: booking.linkedReservations,
|
||||
})
|
||||
|
||||
const packagesInput = {
|
||||
adults: booking.adults,
|
||||
children: booking.childrenAges.length,
|
||||
endDate: toDate,
|
||||
hotelId: hotel.operaId,
|
||||
lang,
|
||||
startDate: fromDate,
|
||||
packageCodes: [
|
||||
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
|
||||
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
|
||||
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
|
||||
],
|
||||
}
|
||||
const supportedCards = hotel.merchantInformationData.cards
|
||||
const savedPaymentCardsInput = { supportedCards }
|
||||
|
||||
const hasBreakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
|
||||
const alreadyHasABreakfastSelection =
|
||||
!hasBreakfastPackage && !breakfastIncluded
|
||||
if (alreadyHasABreakfastSelection) {
|
||||
void getPackages(packagesInput)
|
||||
}
|
||||
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
|
||||
|
||||
const ancillaryPackages = await getAncillaryPackages({
|
||||
fromDate,
|
||||
hotelId: hotel.operaId,
|
||||
toDate,
|
||||
})
|
||||
|
||||
let breakfastPackages = null
|
||||
if (alreadyHasABreakfastSelection) {
|
||||
breakfastPackages = await getPackages(packagesInput)
|
||||
}
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely(
|
||||
savedPaymentCardsInput
|
||||
)
|
||||
|
||||
const imageSrc =
|
||||
hotel.hotelContent.images.imageSizes.large ??
|
||||
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
|
||||
hotel.galleryImages[0]?.imageSizes.large
|
||||
|
||||
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
|
||||
const promoUrl = env.HIDE_FOR_NEXT_RELEASE
|
||||
? new URL(getCurrentWebUrl({ path: "/", lang }))
|
||||
: new URL(`${baseUrl}/${lang}/`)
|
||||
|
||||
promoUrl.searchParams.set("hotel", hotel.operaId)
|
||||
|
||||
return (
|
||||
<MyStayProvider
|
||||
bookingConfirmation={bookingConfirmation}
|
||||
breakfastPackages={breakfastPackages}
|
||||
lang={params.lang}
|
||||
linkedReservationsPromise={linkedReservationsPromise}
|
||||
refId={refId}
|
||||
roomCategories={roomCategories}
|
||||
savedCreditCards={savedCreditCards}
|
||||
>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.blurOverlay} />
|
||||
{imageSrc && (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={imageSrc}
|
||||
alt={hotel.name}
|
||||
fill
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Header cityName={hotel.cityName} name={hotel.name} />
|
||||
<ReferenceCard />
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
packages={breakfastPackages}
|
||||
user={user}
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Rooms user={user} />
|
||||
|
||||
<BookingSummary hotel={hotel} />
|
||||
<Promo
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Book your next stay",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
buttonText={intl.formatMessage({
|
||||
defaultMessage: "Explore Scandic hotels",
|
||||
})}
|
||||
href={promoUrl.toString()}
|
||||
image={hotel.hotelContent.images}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SidePeek />
|
||||
</MyStayProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (access === ERROR_BAD_REQUEST) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.form}>
|
||||
<AdditionalInfoForm
|
||||
confirmationNumber={confirmationNumber}
|
||||
lastName={lastName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (access === ERROR_UNAUTHORIZED) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.logIn}>
|
||||
<Typography variant="Title/md">
|
||||
<h1>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "You need to be logged in to view your booking",
|
||||
})}
|
||||
</h1>
|
||||
</Typography>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"And you need to be logged in with the same member account that made the booking.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return notFound()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import useRedeemFlow from "./useRedeemFlow"
|
||||
|
||||
import styles from "./redeem.module.css"
|
||||
|
||||
export function ConfirmClose({ close }: { close: VoidFunction }) {
|
||||
export function ConfirmClose({ close }: { close: () => void }) {
|
||||
const intl = useIntl()
|
||||
const { setRedeemStep } = useRedeemFlow()
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { locales } from "../locales"
|
||||
|
||||
import styles from "./desktop.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
@@ -23,7 +25,6 @@ import type { DatePickerRangeProps } from "@/types/components/datepicker"
|
||||
export default function DatePickerRangeDesktop({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedRange,
|
||||
}: DatePickerRangeProps) {
|
||||
const lang = useLang()
|
||||
|
||||
@@ -12,6 +12,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { locales } from "../locales"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
@@ -20,7 +22,6 @@ import type { DatePickerRangeProps } from "@/types/components/datepicker"
|
||||
export default function DatePickerRangeMobile({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedRange,
|
||||
}: DatePickerRangeProps) {
|
||||
const lang = useLang()
|
||||
|
||||
@@ -15,6 +15,8 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { locales } from "../locales"
|
||||
|
||||
import styles from "./desktop.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
@@ -23,7 +25,6 @@ import type { DatePickerSingleProps } from "@/types/components/datepicker"
|
||||
export default function DatePickerSingleDesktop({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedDate,
|
||||
startMonth,
|
||||
}: DatePickerSingleProps) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { locales } from "../locales"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
import classNames from "react-day-picker/style.module.css"
|
||||
|
||||
@@ -18,7 +20,6 @@ import type { DatePickerSingleProps } from "@/types/components/datepicker"
|
||||
export default function DatePickerSingleMobile({
|
||||
close,
|
||||
handleOnSelect,
|
||||
locales,
|
||||
selectedDate,
|
||||
hideHeader,
|
||||
}: DatePickerSingleProps) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -20,14 +17,6 @@ import type { DateRange } from "react-day-picker"
|
||||
|
||||
import type { DatePickerFormProps } from "@/types/components/datepicker"
|
||||
|
||||
const locales = {
|
||||
[Lang.da]: da,
|
||||
[Lang.de]: de,
|
||||
[Lang.fi]: fi,
|
||||
[Lang.no]: nb,
|
||||
[Lang.sv]: sv,
|
||||
}
|
||||
|
||||
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
@@ -163,7 +152,6 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
<DatePickerRangeDesktop
|
||||
close={close}
|
||||
handleOnSelect={handleSelectDate}
|
||||
locales={locales}
|
||||
// DayPicker lib needs Daterange in form as below to show appropriate UI
|
||||
selectedRange={{
|
||||
from: dt(selectedDate.fromDate).toDate(),
|
||||
@@ -175,7 +163,6 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
<DatePickerRangeMobile
|
||||
close={close}
|
||||
handleOnSelect={handleSelectDate}
|
||||
locales={locales}
|
||||
// DayPicker lib needs Daterange in form as below to show appropriate UI
|
||||
selectedRange={{
|
||||
from: dt(selectedDate.fromDate).toDate(),
|
||||
|
||||
11
apps/scandic-web/components/DatePicker/locales.ts
Normal file
11
apps/scandic-web/components/DatePicker/locales.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
export const locales = {
|
||||
[Lang.da]: da,
|
||||
[Lang.de]: de,
|
||||
[Lang.fi]: fi,
|
||||
[Lang.no]: nb,
|
||||
[Lang.sv]: sv,
|
||||
}
|
||||
@@ -30,8 +30,6 @@ export default function PriceDetails() {
|
||||
.startOf("day")
|
||||
.diff(dt(fromDate).startOf("day"), "days")
|
||||
|
||||
console.log({ rooms })
|
||||
|
||||
const totalPrice = rooms.reduce<Price>(
|
||||
(total, room) => {
|
||||
if (!room) {
|
||||
|
||||
@@ -10,13 +10,13 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
import MySavedCards from "@/components/HotelReservation/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/PaymentOption"
|
||||
import Modal from "@/components/Modal"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import { trackPaymentSectionOpen } from "@/utils/tracking/booking"
|
||||
|
||||
import MySavedCards from "../Payment/MySavedCards"
|
||||
import PaymentOption from "../Payment/PaymentOption"
|
||||
import PaymentOptionsGroup from "../Payment/PaymentOptionsGroup"
|
||||
import TermsAndConditions from "../Payment/TermsAndConditions"
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import MySavedCards from "@/components/HotelReservation/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/PaymentOption"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -40,9 +42,7 @@ import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
|
||||
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
|
||||
import MySavedCards from "./MySavedCards"
|
||||
import PaymentAlert from "./PaymentAlert"
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import PaymentOptionsGroup from "./PaymentOptionsGroup"
|
||||
import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
import TermsAndConditions from "./TermsAndConditions"
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
|
||||
import PaymentOptionsGroup from "../EnterDetails/Payment/PaymentOptionsGroup"
|
||||
import PaymentOption from "../PaymentOption"
|
||||
import PaymentOptionsGroup from "../PaymentOptionsGroup"
|
||||
|
||||
import styles from "./mySavedCards.module.css"
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import MySavedCards from "@/components/HotelReservation/EnterDetails/Payment/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/EnterDetails/Payment/PaymentOption"
|
||||
import PaymentOptionsGroup from "@/components/HotelReservation/EnterDetails/Payment/PaymentOptionsGroup"
|
||||
import MySavedCards from "@/components/HotelReservation/MySavedCards"
|
||||
import PaymentOption from "@/components/HotelReservation/PaymentOption"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
@@ -144,8 +144,8 @@ export default function ConfirmationStep({
|
||||
label={
|
||||
savedCreditCards?.length
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "OTHER",
|
||||
})
|
||||
defaultMessage: "OTHER",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
|
||||
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/utils/ancillaries"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
useAddAncillaryStore,
|
||||
} from "@/stores/my-stay/add-ancillary-flow"
|
||||
|
||||
import {
|
||||
buildAncillaryPackages,
|
||||
clearAncillarySessionData,
|
||||
generateDeliveryOptions,
|
||||
getAncillarySessionData,
|
||||
setAncillarySessionData,
|
||||
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
|
||||
import Image from "@/components/Image"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Modal from "@/components/Modal"
|
||||
@@ -33,13 +40,6 @@ import {
|
||||
trackGlaAncillaryAttempt,
|
||||
} from "@/utils/tracking/myStay"
|
||||
|
||||
import {
|
||||
buildAncillaryPackages,
|
||||
clearAncillarySessionData,
|
||||
generateDeliveryOptions,
|
||||
getAncillarySessionData,
|
||||
setAncillarySessionData,
|
||||
} from "../../utils"
|
||||
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
|
||||
import ActionButtons from "./ActionButtons"
|
||||
import PriceDetails from "./PriceDetails"
|
||||
@@ -124,10 +124,7 @@ export default function AddAncillaryFlowModal({
|
||||
const addAncillary = trpc.booking.packages.useMutation()
|
||||
|
||||
const { guaranteeBooking, isLoading, handleGuaranteeError } =
|
||||
useGuaranteeBooking({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
isAncillaryFlow: true,
|
||||
})
|
||||
useGuaranteeBooking(booking.confirmationNumber, true)
|
||||
|
||||
function validateTermsAndConditions(data: AncillaryFormData): boolean {
|
||||
if (!data.termsAndConditions) {
|
||||
|
||||
@@ -5,18 +5,17 @@ import { useEffect } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import {
|
||||
buildAncillaryPackages,
|
||||
clearAncillarySessionData,
|
||||
getAncillarySessionData,
|
||||
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import {
|
||||
trackAncillaryFailed,
|
||||
trackAncillarySuccess,
|
||||
} from "@/utils/tracking/myStay"
|
||||
|
||||
import {
|
||||
buildAncillaryPackages,
|
||||
clearAncillarySessionData,
|
||||
getAncillarySessionData,
|
||||
} from "../utils"
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export default function GuaranteeAncillaryHandler({
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
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 MySavedCards from "../../EnterDetails/Payment/MySavedCards"
|
||||
import PaymentOption from "../../EnterDetails/Payment/PaymentOption"
|
||||
import PaymentOptionsGroup from "../../EnterDetails/Payment/PaymentOptionsGroup"
|
||||
import { type GuaranteeFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./guaranteeLateArrival.module.css"
|
||||
|
||||
import type { CreditCard } from "@/types/user"
|
||||
|
||||
export interface GuaranteeLateArrivalProps {
|
||||
savedCreditCards: CreditCard[] | null
|
||||
refId: string
|
||||
}
|
||||
|
||||
export default function GuaranteeLateArrival({
|
||||
savedCreditCards,
|
||||
refId,
|
||||
}: GuaranteeLateArrivalProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const {
|
||||
actions: { handleCloseView, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const methods = useForm<GuaranteeFormData>({
|
||||
defaultValues: {
|
||||
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: bookedRoom.confirmationNumber,
|
||||
handleBookingCompleted: router.refresh,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleGuaranteeLateArrival = (data: GuaranteeFormData) => {
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
trackGlaSaveCardAttempt(bookedRoom.hotelId, savedCreditCard, "yes")
|
||||
if (bookedRoom.confirmationNumber) {
|
||||
const card = savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined
|
||||
guaranteeBooking.mutate({
|
||||
confirmationNumber: bookedRoom.confirmationNumber,
|
||||
language: lang,
|
||||
...(card !== undefined && { 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!",
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Guarantee late arrival",
|
||||
})}
|
||||
onClose={handleCloseModal}
|
||||
content={
|
||||
<>
|
||||
<Caption>
|
||||
{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.",
|
||||
})}
|
||||
</Caption>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"In case of no-show you will be charged for the first night.",
|
||||
})}
|
||||
</Caption>
|
||||
{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}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{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>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<Checkbox name="termsAndConditions">
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "I accept the terms and conditions",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.guaranteeCost}>
|
||||
<div className={styles.guaranteeCostText}>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Guarantee cost",
|
||||
})}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Your card will only be used for authorisation",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
<Body textTransform="bold">
|
||||
{formatPrice(intl, 0, bookedRoom.currencyCode)}
|
||||
</Body>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
primaryAction={{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Guarantee",
|
||||
}),
|
||||
onClick: methods.handleSubmit(handleGuaranteeLateArrival),
|
||||
intent: "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
}),
|
||||
onClick: handleCloseView,
|
||||
intent: "text",
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./guestDetails.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
interface DetailsProps {
|
||||
booking: Room
|
||||
user: SafeUser
|
||||
}
|
||||
|
||||
export default function Details({ booking, user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: booking.guest.firstName,
|
||||
lastName: booking.guest.lastName,
|
||||
email: booking.guest.email,
|
||||
phoneNumber: booking.guest.phoneNumber,
|
||||
countryCode: booking.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const isMemberBooking =
|
||||
booking.guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
utils.booking.get.invalidate({
|
||||
confirmationNumber: data.confirmationNumber,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Guest details updated",
|
||||
})
|
||||
)
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.href,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.guestDetails}>
|
||||
{isMemberBooking && user.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userDetailsTitle}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Your member tier",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.memberLevel}>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
rows={1}
|
||||
className={styles.memberLevelIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
<div className={styles.totalPointsText}>
|
||||
<MaterialIcon icon="diamond" color="Icon/Intense" />
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total points",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.membership.currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{booking.guest.firstName} {booking.guest.lastName}
|
||||
</p>
|
||||
</Typography>
|
||||
{isMemberBooking && user.membership && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.memberNumber}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Member no. {nr}",
|
||||
},
|
||||
{
|
||||
nr: user.membership.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.contactInfoMobile}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.contactInfoDesktop}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={"secondary"}
|
||||
onClick={handleModifyMemberDetails}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color="Icon/Interactive/Default"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color={
|
||||
booking.isCancelled
|
||||
? "Icon/Interactive/Disabled"
|
||||
: "Icon/Interactive/Default"
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
{isModifyGuestDetailsOpen && (
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
booking.guest && (
|
||||
<ModifyContact
|
||||
guest={booking.guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Save updates",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm",
|
||||
}),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: () => form.handleSubmit(onSubmit)(),
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Cancel",
|
||||
}),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,326 +1,22 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import Details from "./Details"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { type Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./guestDetails.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { User } from "@/types/user"
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
interface GuestDetailsProps {
|
||||
user: User | null
|
||||
booking: Room
|
||||
updateRoom: (room: Room) => void
|
||||
selectedRoom?: Room
|
||||
user: SafeUser
|
||||
}
|
||||
|
||||
export default function GuestDetails({
|
||||
selectedRoom,
|
||||
user,
|
||||
booking,
|
||||
updateRoom,
|
||||
}: GuestDetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const booking = useMyStayStore((state) => state.bookedRoom)
|
||||
const room = selectedRoom ? selectedRoom : booking
|
||||
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: booking.guest.firstName,
|
||||
lastName: booking.guest.lastName,
|
||||
email: booking.guest.email,
|
||||
phoneNumber: booking.guest.phoneNumber,
|
||||
countryCode: booking.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const isMemberBooking =
|
||||
booking.guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
updateRoom({
|
||||
...booking,
|
||||
guest: {
|
||||
...booking.guest,
|
||||
email: data.guest.email,
|
||||
phoneNumber: data.guest.phoneNumber,
|
||||
countryCode: data.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Guest details updated",
|
||||
})
|
||||
)
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.href,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.guestDetails}>
|
||||
{isMemberBooking && user.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userDetailsTitle}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Your member tier",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.memberLevel}>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
rows={1}
|
||||
className={styles.memberLevelIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
<div className={styles.totalPointsText}>
|
||||
<MaterialIcon icon="diamond" color="Icon/Intense" />
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total points",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.membership.currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{booking.guest.firstName} {booking.guest.lastName}
|
||||
</p>
|
||||
</Typography>
|
||||
{isMemberBooking && user.membership && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.memberNumber}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Member no. {nr}",
|
||||
},
|
||||
{
|
||||
nr: user.membership.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.contactInfoMobile}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.contactInfoDesktop}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={"secondary"}
|
||||
onClick={handleModifyMemberDetails}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color="Icon/Interactive/Default"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color={
|
||||
booking.isCancelled
|
||||
? "Icon/Interactive/Disabled"
|
||||
: "Icon/Interactive/Default"
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
{isModifyGuestDetailsOpen && (
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
booking.guest && (
|
||||
<ModifyContact
|
||||
guest={booking.guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Save updates",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm",
|
||||
}),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: () => form.handleSubmit(onSubmit)(),
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Cancel",
|
||||
}),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <Details booking={room} user={user} />
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
|
||||
export async function Header({ hotel }: Pick<BookingConfirmation, "hotel">) {
|
||||
export async function Header({
|
||||
cityName,
|
||||
name,
|
||||
}: Pick<Hotel, "cityName" | "name">) {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<header>
|
||||
@@ -20,8 +23,8 @@ export async function Header({ hotel }: Pick<BookingConfirmation, "hotel">) {
|
||||
" "
|
||||
}
|
||||
</BiroScript>
|
||||
<span className={styles.hotelName}>{hotel.name}</span>
|
||||
{hotel.cityName}
|
||||
<span className={styles.hotelName}>{name}</span>
|
||||
{cityName}
|
||||
</Title>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { trackMyStayPageLink } from "@/utils/tracking"
|
||||
|
||||
import styles from "../actionPanel.module.css"
|
||||
|
||||
export default function AddToCalendarButton({
|
||||
onPress,
|
||||
disabled,
|
||||
}: {
|
||||
onPress: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
const handleAddToCalendar = () => {
|
||||
trackMyStayPageLink("add to calendar")
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="icon"
|
||||
intent="text"
|
||||
className={styles.button}
|
||||
onPress={handleAddToCalendar}
|
||||
disabled={disabled}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Add to calendar",
|
||||
})}
|
||||
<MaterialIcon icon="calendar_add_on" color="CurrentColor" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import PriceContainer from "../../../PriceContainer"
|
||||
import { useCheckedRoomsCounts } from "../utils"
|
||||
|
||||
import type {
|
||||
CancelStayFormValues,
|
||||
PriceContainerProps,
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
export default function CancelStayPriceContainer({
|
||||
roomDetails,
|
||||
stayDetails,
|
||||
}: PriceContainerProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { getValues } = useFormContext<CancelStayFormValues>()
|
||||
const formRooms = getValues("rooms")
|
||||
|
||||
const checkedRoomsDetails = useCheckedRoomsCounts(
|
||||
roomDetails,
|
||||
formRooms,
|
||||
intl
|
||||
)
|
||||
|
||||
return (
|
||||
<PriceContainer
|
||||
text={intl.formatMessage({
|
||||
defaultMessage: "Cancellation cost",
|
||||
})}
|
||||
price={0}
|
||||
currencyCode={roomDetails.currencyCode}
|
||||
nightsText={stayDetails.nightsText}
|
||||
adultsText={checkedRoomsDetails.adultsText}
|
||||
childrenText={checkedRoomsDetails.childrenText}
|
||||
totalChildren={checkedRoomsDetails.totalChildren}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import CancelStayPriceContainer from "../CancelStayPriceContainer"
|
||||
|
||||
import styles from "../cancelStay.module.css"
|
||||
|
||||
import type {
|
||||
CancelStayConfirmationProps,
|
||||
CancelStayFormValues,
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
export function CancelStayConfirmation({
|
||||
hotel,
|
||||
stayDetails,
|
||||
}: CancelStayConfirmationProps) {
|
||||
const intl = useIntl()
|
||||
const { watch } = useFormContext<CancelStayFormValues>()
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const { multiRoom } = bookedRoom
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalText}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.",
|
||||
},
|
||||
{
|
||||
hotel: hotel.name,
|
||||
checkInDate: stayDetails.checkInDate,
|
||||
checkOutDate: stayDetails.checkOutDate,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "No charges were made.",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
{multiRoom && (
|
||||
<>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Select rooms",
|
||||
})}
|
||||
</Body>
|
||||
|
||||
<div className={styles.rooms}>
|
||||
{watch("rooms").map((room, index) => {
|
||||
// Find room details from store by confirmationNumber
|
||||
const roomDetail =
|
||||
linkedReservationRooms.find(
|
||||
(detail) =>
|
||||
detail.confirmationNumber === room.confirmationNumber
|
||||
) ?? bookedRoom
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.confirmationNumber}
|
||||
className={styles.roomContainer}
|
||||
>
|
||||
<Checkbox
|
||||
name={`rooms.${index}.checked`}
|
||||
registerOptions={{
|
||||
disabled:
|
||||
!roomDetail.isCancelable || roomDetail.isCancelled,
|
||||
}}
|
||||
>
|
||||
<div className={styles.roomInfo}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: index + 1,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
{roomDetail && (
|
||||
<>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomDetail.roomName}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{watch("rooms").some((room) => room.checked) && (
|
||||
<CancelStayPriceContainer
|
||||
roomDetails={bookedRoom}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import CancelStayPriceContainer from "../CancelStayPriceContainer"
|
||||
|
||||
import styles from "../cancelStay.module.css"
|
||||
|
||||
import type { FinalConfirmationProps } from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
export function FinalConfirmation({ stayDetails }: FinalConfirmationProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalText}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Are you sure you want to continue with the cancellation?",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
{bookedRoom && (
|
||||
<CancelStayPriceContainer
|
||||
roomDetails={bookedRoom}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import {
|
||||
type Room,
|
||||
useMyStayRoomDetailsStore,
|
||||
} from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackCancelStay } from "@/utils/tracking"
|
||||
|
||||
import type {
|
||||
CancelStayFormValues,
|
||||
CancelStayProps,
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
|
||||
interface UseCancelStayProps extends Omit<CancelStayProps, "hotel"> {
|
||||
checkedRooms: CancelStayFormValues["rooms"]
|
||||
}
|
||||
|
||||
export default function useCancelStay({
|
||||
handleCloseModal,
|
||||
checkedRooms,
|
||||
}: UseCancelStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
actions: { setIsLoading },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const updateBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateBookedRoom
|
||||
)
|
||||
|
||||
const updateLinkedReservationRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateLinkedReservationRoom
|
||||
)
|
||||
|
||||
const cancelStay = trpc.booking.cancel.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
})
|
||||
|
||||
async function handleCancelStay() {
|
||||
if (!bookedRoom.confirmationNumber) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (const room of checkedRooms) {
|
||||
let targetRoom: Room | undefined
|
||||
|
||||
// Check if this is the main booked room
|
||||
if (room.confirmationNumber === bookedRoom.confirmationNumber) {
|
||||
targetRoom = bookedRoom
|
||||
}
|
||||
// Check if this is a linked reservation room
|
||||
else {
|
||||
targetRoom = linkedReservationRooms.find(
|
||||
(r) => r.confirmationNumber === room.confirmationNumber
|
||||
)
|
||||
}
|
||||
|
||||
if (!targetRoom?.confirmationNumber) {
|
||||
errors.push(room.confirmationNumber)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await cancelStay.mutateAsync({
|
||||
confirmationNumber: targetRoom.confirmationNumber,
|
||||
language: lang,
|
||||
})
|
||||
|
||||
if (response) {
|
||||
results.push(room.confirmationNumber)
|
||||
const cancelledRoom = response.rooms.find(
|
||||
(r) => r.confirmationNumber === targetRoom?.confirmationNumber
|
||||
)
|
||||
|
||||
if (cancelledRoom) {
|
||||
if (
|
||||
targetRoom.confirmationNumber === bookedRoom.confirmationNumber
|
||||
) {
|
||||
// Update main booked room
|
||||
updateBookedRoom({
|
||||
...bookedRoom,
|
||||
isCancelled: true,
|
||||
cancellationNumber: cancelledRoom.cancellationNumber,
|
||||
})
|
||||
} else {
|
||||
// Update linked reservation room
|
||||
updateLinkedReservationRoom({
|
||||
...targetRoom,
|
||||
isCancelled: true,
|
||||
cancellationNumber: cancelledRoom.cancellationNumber,
|
||||
})
|
||||
}
|
||||
|
||||
trackCancelStay(
|
||||
bookedRoom.hotelId,
|
||||
cancelledRoom.confirmationNumber
|
||||
)
|
||||
}
|
||||
} else {
|
||||
errors.push(room.confirmationNumber)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error cancelling room ${targetRoom.confirmationNumber}:`,
|
||||
error
|
||||
)
|
||||
errors.push(room.confirmationNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate toast based on results
|
||||
if (results.length > 0 && errors.length === 0) {
|
||||
// All selected rooms cancelled successfully
|
||||
toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Your stay was cancelled. Cancellation cost: 0 {currency}. We're sorry to see that the plans didn't work out",
|
||||
},
|
||||
{ currency: bookedRoom.currencyCode }
|
||||
)
|
||||
)
|
||||
} else if (results.length > 0 && errors.length > 0) {
|
||||
// Some rooms cancelled, some failed
|
||||
toast.warning(
|
||||
intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Some rooms were cancelled successfully, but we encountered issues with others. Please contact customer service for assistance.",
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// No rooms cancelled successfully
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
handleCloseModal()
|
||||
} catch (error) {
|
||||
console.error("Error in handleCancelStay:", error)
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleCancelStay,
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import useCancelStay from "./hooks/useCancelStay"
|
||||
import { CancelStayConfirmation } from "./Confirmation"
|
||||
import { FinalConfirmation } from "./FinalConfirmation"
|
||||
import { formatStayDetails, getDefaultRooms } from "./utils"
|
||||
|
||||
import {
|
||||
type CancelStayFormValues,
|
||||
cancelStaySchema,
|
||||
} from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
|
||||
interface CancelStayProps {
|
||||
hotel: Hotel
|
||||
}
|
||||
|
||||
export default function CancelStay({ hotel }: CancelStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const form = useForm<CancelStayFormValues>({
|
||||
resolver: zodResolver(cancelStaySchema),
|
||||
defaultValues: {
|
||||
rooms: getDefaultRooms(bookedRoom),
|
||||
},
|
||||
})
|
||||
const {
|
||||
currentStep,
|
||||
isLoading,
|
||||
actions: { handleForward, handleCloseView, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const { rooms } = form.watch()
|
||||
|
||||
const { handleCancelStay } = useCancelStay({
|
||||
handleCloseModal,
|
||||
checkedRooms: rooms.filter((room) => room.checked),
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
const stayDetails = formatStayDetails({ bookedRoom, lang, intl })
|
||||
|
||||
function getModalCopy() {
|
||||
if (isFirstStep) {
|
||||
return {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Cancel stay",
|
||||
}),
|
||||
primaryLabel: intl.formatMessage({
|
||||
defaultMessage: "Cancel stay",
|
||||
}),
|
||||
secondaryLabel: intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Confirm cancellation",
|
||||
}),
|
||||
primaryLabel: intl.formatMessage({
|
||||
defaultMessage: "Confirm cancellation",
|
||||
}),
|
||||
secondaryLabel: intl.formatMessage({
|
||||
defaultMessage: "Don't cancel",
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getModalContent() {
|
||||
if (bookedRoom && isFirstStep)
|
||||
return <CancelStayConfirmation hotel={hotel} stayDetails={stayDetails} />
|
||||
|
||||
if (bookedRoom && !isFirstStep)
|
||||
return <FinalConfirmation stayDetails={stayDetails} />
|
||||
|
||||
if (!bookedRoom && isFirstStep)
|
||||
return (
|
||||
<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.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isFormValid = rooms?.some((room) => room.checked)
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={getModalCopy().title}
|
||||
content={getModalContent()}
|
||||
onClose={handleCloseModal}
|
||||
primaryAction={
|
||||
bookedRoom
|
||||
? {
|
||||
label: getModalCopy().primaryLabel,
|
||||
onClick: isFirstStep ? handleForward : handleCancelStay,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
isLoading: isLoading,
|
||||
disabled: !isFormValid,
|
||||
}
|
||||
: null
|
||||
}
|
||||
secondaryAction={{
|
||||
label: getModalCopy().secondaryLabel,
|
||||
onClick: isFirstStep ? handleCloseView : handleCloseModal,
|
||||
intent: "text",
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
import type { CancelStayFormValues } from "@/types/components/hotelReservation/myStay/cancelStay"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
export function getDefaultRooms(room: Room) {
|
||||
const { multiRoom, confirmationNumber, linkedReservations = [] } = room
|
||||
|
||||
if (!multiRoom) {
|
||||
return [{ id: "1", checked: true, confirmationNumber }]
|
||||
}
|
||||
|
||||
const mainRoom = { id: "1", checked: false, confirmationNumber }
|
||||
const linkedRooms = linkedReservations.map((reservation, index) => ({
|
||||
id: `${index + 2}`,
|
||||
checked: false,
|
||||
confirmationNumber: reservation.confirmationNumber,
|
||||
}))
|
||||
|
||||
return [mainRoom, ...linkedRooms]
|
||||
}
|
||||
|
||||
export function formatStayDetails({
|
||||
bookedRoom,
|
||||
lang,
|
||||
intl,
|
||||
}: {
|
||||
bookedRoom: Room
|
||||
lang: string
|
||||
intl: IntlShape
|
||||
}) {
|
||||
const {
|
||||
multiRoom,
|
||||
adults,
|
||||
childrenAges,
|
||||
linkedReservations,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
} = bookedRoom
|
||||
|
||||
const totalAdults = multiRoom
|
||||
? linkedReservations.reduce((acc, reservation) => {
|
||||
return acc + reservation.adults
|
||||
}, adults)
|
||||
: adults
|
||||
const totalChildren = multiRoom
|
||||
? linkedReservations.reduce((acc, reservation) => {
|
||||
return acc + reservation.children
|
||||
}, childrenAges.length)
|
||||
: childrenAges.length
|
||||
|
||||
const checkInDateFormatted = dt(checkInDate)
|
||||
.locale(lang)
|
||||
.format("dddd D MMM YYYY")
|
||||
const checkOutDateFormatted = dt(checkOutDate)
|
||||
.locale(lang)
|
||||
.format("dddd D MMM YYYY")
|
||||
const diff = dt(checkOutDate)
|
||||
.startOf("day")
|
||||
.diff(dt(checkInDate).startOf("day"), "days")
|
||||
|
||||
const nightsText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: diff }
|
||||
)
|
||||
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 {
|
||||
checkInDate: checkInDateFormatted,
|
||||
checkOutDate: checkOutDateFormatted,
|
||||
nightsText,
|
||||
adultsText,
|
||||
childrenText,
|
||||
totalChildren,
|
||||
}
|
||||
}
|
||||
|
||||
function getMatchedRooms(
|
||||
roomDetails: Room,
|
||||
checkedConfirmationNumbers: string[]
|
||||
) {
|
||||
let matchedRooms = []
|
||||
|
||||
// Main booking
|
||||
if (checkedConfirmationNumbers.includes(roomDetails.confirmationNumber)) {
|
||||
matchedRooms.push({
|
||||
adults: roomDetails.adults,
|
||||
children: roomDetails.childrenAges.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Linked reservations
|
||||
if (roomDetails.linkedReservations) {
|
||||
roomDetails.linkedReservations.forEach((reservation) => {
|
||||
if (checkedConfirmationNumbers.includes(reservation.confirmationNumber))
|
||||
matchedRooms.push({
|
||||
adults: reservation.adults,
|
||||
children: reservation.children,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return matchedRooms
|
||||
}
|
||||
|
||||
function calculateTotals(matchedRooms: { adults: number; children: number }[]) {
|
||||
const totalAdults = matchedRooms.reduce((sum, room) => sum + room.adults, 0)
|
||||
const totalChildren = matchedRooms.reduce(
|
||||
(sum, room) => sum + room.children,
|
||||
0
|
||||
)
|
||||
return { totalAdults, totalChildren }
|
||||
}
|
||||
|
||||
export const useCheckedRoomsCounts = (
|
||||
roomDetails: Room,
|
||||
formRooms: CancelStayFormValues["rooms"],
|
||||
intl: IntlShape
|
||||
) => {
|
||||
const checkedFormRooms = formRooms.filter((room) => room.checked)
|
||||
const checkedConfirmationNumbers = checkedFormRooms
|
||||
.map((room) => room.confirmationNumber)
|
||||
.filter(
|
||||
(confirmationNumber): confirmationNumber is string =>
|
||||
confirmationNumber !== null && confirmationNumber !== undefined
|
||||
)
|
||||
|
||||
const matchedRooms = getMatchedRooms(roomDetails, checkedConfirmationNumbers)
|
||||
const { totalAdults, totalChildren } = calculateTotals(matchedRooms)
|
||||
|
||||
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 { adultsText, childrenText, totalChildren }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.dateComparison {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.dateGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dateHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import PriceContainer from "@/components/HotelReservation/MyStay/ManageStay/ActionPanel/PriceContainer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./confirmation.module.css"
|
||||
|
||||
interface ConfirmationProps {
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
stayDetails: {
|
||||
checkInDate: string
|
||||
checkOutDate: string
|
||||
nightsText: string
|
||||
adultsText: string
|
||||
childrenText: string
|
||||
totalChildren?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function Confirmation({
|
||||
oldPrice,
|
||||
newPrice,
|
||||
stayDetails,
|
||||
}: ConfirmationProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { getValues } = useFormContext()
|
||||
const { currencyCode } = useMyStayTotalPriceStore()
|
||||
|
||||
const formValues = getValues()
|
||||
|
||||
const originalCheckIn = dt(stayDetails.checkInDate)
|
||||
.locale(lang)
|
||||
.format("dddd, DD MMM, YYYY")
|
||||
const originalCheckOut = dt(stayDetails.checkOutDate)
|
||||
.locale(lang)
|
||||
.format("dddd, DD MMM, YYYY")
|
||||
const newCheckIn = dt(formValues.checkInDate)
|
||||
.locale(lang)
|
||||
.format("dddd, DD MMM, YYYY")
|
||||
const newCheckOut = dt(formValues.checkOutDate)
|
||||
.locale(lang)
|
||||
.format("dddd, DD MMM, YYYY")
|
||||
|
||||
const diff = dt(newCheckOut)
|
||||
.startOf("day")
|
||||
.diff(dt(newCheckIn).startOf("day"), "days")
|
||||
|
||||
const nightsText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.dateComparison}>
|
||||
<div className={styles.dateGroup}>
|
||||
<div className={styles.dateHeader}>
|
||||
<Caption
|
||||
color="uiTextMediumContrast"
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Old dates",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{oldPrice} {currencyCode}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.dates}>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
color="uiTextMediumContrast"
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Check-in",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="uiTextMediumContrast">{originalCheckIn}</Body>
|
||||
</div>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
color="uiTextMediumContrast"
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Check-out",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="uiTextMediumContrast">{originalCheckOut}</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider color="primaryLightSubtle" />
|
||||
|
||||
<div className={styles.dateGroup}>
|
||||
<div className={styles.dateHeader}>
|
||||
<Caption color="red" type="bold" textTransform="uppercase">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "New dates",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="red">
|
||||
{newPrice} {currencyCode}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.dates}>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
color="uiTextMediumContrast"
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Check-in",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="uiTextMediumContrast">{newCheckIn}</Body>
|
||||
</div>
|
||||
<div className={styles.date}>
|
||||
<Caption
|
||||
color="uiTextMediumContrast"
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Check-out",
|
||||
})}
|
||||
</Caption>
|
||||
<Body color="uiTextMediumContrast">{newCheckOut}</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PriceContainer
|
||||
text={intl.formatMessage({
|
||||
defaultMessage: "To be paid",
|
||||
})}
|
||||
price={newPrice}
|
||||
currencyCode={currencyCode}
|
||||
nightsText={nightsText}
|
||||
adultsText={stayDetails.adultsText}
|
||||
childrenText={stayDetails.childrenText}
|
||||
totalChildren={stayDetails.totalChildren}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import type { UseFormGetValues } from "react-hook-form"
|
||||
|
||||
import type { ModifyDateSchema } from "@/types/components/hotelReservation/myStay/modifyDate"
|
||||
|
||||
interface UseModifyStayOptions {
|
||||
isLoggedIn?: boolean
|
||||
getFormValues: UseFormGetValues<ModifyDateSchema>
|
||||
handleCloseModal: () => void
|
||||
}
|
||||
|
||||
export default function useModifyStay({
|
||||
isLoggedIn,
|
||||
getFormValues,
|
||||
handleCloseModal,
|
||||
}: UseModifyStayOptions) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
actions: { setIsLoading },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const updateBookedRoom = useMyStayRoomDetailsStore(
|
||||
(state) => state.actions.updateBookedRoom
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const updateBooking = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (updatedBooking) => {
|
||||
if (!updatedBooking) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update your stay",
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
// Update room details with server response data
|
||||
updateBookedRoom({
|
||||
...bookedRoom,
|
||||
checkInDate: updatedBooking.checkInDate,
|
||||
checkOutDate: updatedBooking.checkOutDate,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Your stay was updated",
|
||||
})
|
||||
)
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update your stay",
|
||||
})
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function checkAvailability() {
|
||||
const formValues = getFormValues()
|
||||
|
||||
if (!formValues.checkInDate || !formValues.checkOutDate) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Please select dates",
|
||||
})
|
||||
)
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const availabilityResults = []
|
||||
let totalNewPrice = 0
|
||||
|
||||
try {
|
||||
const data = await utils.hotel.availability.myStay.fetch({
|
||||
booking: {
|
||||
fromDate: formValues.checkInDate,
|
||||
toDate: formValues.checkOutDate,
|
||||
hotelId: bookedRoom.hotelId,
|
||||
room: {
|
||||
adults: bookedRoom.adults,
|
||||
bookingCode: bookedRoom.bookingCode ?? undefined,
|
||||
childrenInRoom: bookedRoom.childrenInRoom,
|
||||
rateCode: bookedRoom.rateDefinition.rateCode,
|
||||
roomTypeCode: bookedRoom.roomTypeCode,
|
||||
},
|
||||
},
|
||||
lang,
|
||||
})
|
||||
|
||||
if (!data?.selectedRoom || data.selectedRoom.roomsLeft <= 0) {
|
||||
return { success: false, noAvailability: true }
|
||||
}
|
||||
let roomPrice = 0
|
||||
if (isLoggedIn && "member" in data.product && data.product.member) {
|
||||
roomPrice = data.product.member.localPrice.pricePerStay
|
||||
} else if ("public" in data.product && data.product.public) {
|
||||
roomPrice = data.product.public.localPrice.pricePerStay
|
||||
} else if (
|
||||
"corporateCheque" in data.product &&
|
||||
data.product.corporateCheque.localPrice.additionalPricePerStay
|
||||
) {
|
||||
roomPrice =
|
||||
data.product.corporateCheque.localPrice.additionalPricePerStay
|
||||
} else if (
|
||||
"redemption" in data.product &&
|
||||
data.product.redemption.localPrice.additionalPricePerStay
|
||||
) {
|
||||
roomPrice = data.product.redemption.localPrice.additionalPricePerStay
|
||||
}
|
||||
totalNewPrice += roomPrice
|
||||
availabilityResults.push(data)
|
||||
} catch (error) {
|
||||
console.error("Error checking room availability:", error)
|
||||
return { success: false, error: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newRoomPrice: totalNewPrice,
|
||||
results: availabilityResults,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking availability:", error)
|
||||
return { success: false, error: true }
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModifyStay() {
|
||||
const formValues = getFormValues()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await updateBooking.mutateAsync({
|
||||
confirmationNumber: bookedRoom.confirmationNumber,
|
||||
checkInDate: formValues.checkInDate,
|
||||
checkOutDate: formValues.checkOutDate,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error modifying stay:", error)
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update your stay. Please try again later.",
|
||||
})
|
||||
)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkAvailability,
|
||||
handleModifyStay,
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { formatStayDetails } from "../CancelStay/utils"
|
||||
import useModifyStay from "./hooks/useModifyStay"
|
||||
import Confirmation from "./Confirmation"
|
||||
import NewDates from "./NewDates"
|
||||
|
||||
import {
|
||||
type ModifyDateSchema,
|
||||
modifyDateSchema,
|
||||
type ModifyStayProps,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyDate"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function ModifyStay({ isLoggedIn }: ModifyStayProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const [error, setError] = useState(false)
|
||||
const [noAvailability, setNoAvailability] = useState(false)
|
||||
const [newRoomPrice, setNewRoomPrice] = useState(0)
|
||||
|
||||
const form = useForm<ModifyDateSchema>({
|
||||
resolver: zodResolver(modifyDateSchema),
|
||||
defaultValues: {
|
||||
checkInDate: "",
|
||||
checkOutDate: "",
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isLoading,
|
||||
actions: { handleCloseView, handleCloseModal, setCurrentStep },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
|
||||
const stayDetails = formatStayDetails({ bookedRoom, lang, intl })
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const {
|
||||
multiRoom,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
mainRoom,
|
||||
roomPrice,
|
||||
canChangeDate,
|
||||
} = bookedRoom
|
||||
|
||||
const { checkAvailability, handleModifyStay } = useModifyStay({
|
||||
isLoggedIn,
|
||||
getFormValues: form.getValues,
|
||||
handleCloseModal,
|
||||
})
|
||||
|
||||
async function onCheckAvailability() {
|
||||
setError(false)
|
||||
setNoAvailability(false)
|
||||
|
||||
const result = await checkAvailability()
|
||||
|
||||
if (result.success) {
|
||||
setNewRoomPrice(result.newRoomPrice ?? 0)
|
||||
setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
} else {
|
||||
if (result.noAvailability) {
|
||||
setNoAvailability(true)
|
||||
}
|
||||
if (result.error) {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("checkInDate", dt(checkInDate).format("YYYY-MM-DD"))
|
||||
form.setValue("checkOutDate", dt(checkOutDate).format("YYYY-MM-DD"))
|
||||
}, [checkInDate, checkOutDate, form])
|
||||
|
||||
function getModalContent() {
|
||||
if (bookedRoom && isFirstStep && multiRoom) {
|
||||
return (
|
||||
<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.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mainRoom && !canChangeDate) {
|
||||
return (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "Contact customer service",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Please contact customer service to update the dates.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mainRoom && isFirstStep)
|
||||
return (
|
||||
<NewDates
|
||||
mainRoom={bookedRoom}
|
||||
noAvailability={noAvailability}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
if (mainRoom && !isFirstStep)
|
||||
return (
|
||||
<Confirmation
|
||||
oldPrice={roomPrice.perStay.local.price}
|
||||
newPrice={newRoomPrice}
|
||||
stayDetails={stayDetails}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!mainRoom && isFirstStep)
|
||||
return (
|
||||
<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.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={
|
||||
isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "New dates for the stay",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm date change",
|
||||
})
|
||||
}
|
||||
content={getModalContent()}
|
||||
onClose={handleCloseModal}
|
||||
primaryAction={
|
||||
mainRoom && !multiRoom && canChangeDate
|
||||
? {
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Check availability",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm",
|
||||
}),
|
||||
onClick: isFirstStep ? onCheckAvailability : handleModifyStay,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
isLoading: isLoading,
|
||||
disabled: isLoading,
|
||||
}
|
||||
: null
|
||||
}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Cancel",
|
||||
}),
|
||||
onClick: isFirstStep ? handleCloseView : handleCloseModal,
|
||||
intent: "text",
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
.actionPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.actionPanel .menu .button,
|
||||
.actionLink {
|
||||
width: 100%;
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
justify-content: space-between !important;
|
||||
padding: var(--Spacing-x1) 0 !important;
|
||||
}
|
||||
|
||||
.actionLink {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.actionPanel .menu .button:disabled {
|
||||
color: var(--Scandic-Grey-40);
|
||||
}
|
||||
|
||||
.disabledLink {
|
||||
color: var(--Scandic-Grey-40);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x1) 0;
|
||||
width: 100%;
|
||||
}
|
||||
.disabledLink:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 100%;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tag {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--Main-Red-60);
|
||||
font-family: var(--typography-Caption-Labels-fontFamily);
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.actionPanel {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 432px;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 256px;
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"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 { customerService } from "@/constants/currentWebHrefs"
|
||||
import { preliminaryReceipt } from "@/constants/routes/myStay"
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
|
||||
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackMyStayPageLink } from "@/utils/tracking"
|
||||
|
||||
import AddToCalendarButton from "./Actions/AddToCalendarButton"
|
||||
import {
|
||||
checkCancelable,
|
||||
checkCanDownloadInvoice,
|
||||
checkDateModifiable,
|
||||
checkGuaranteeable,
|
||||
isDatetimePast,
|
||||
} from "./utils"
|
||||
|
||||
import styles from "./actionPanel.module.css"
|
||||
|
||||
import type { EventAttributes } from "ics"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
|
||||
interface ActionPanelProps {
|
||||
hotel: Hotel
|
||||
}
|
||||
|
||||
export default function ActionPanel({ hotel }: ActionPanelProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
actions: { setActiveView },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const {
|
||||
confirmationNumber,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
createDateTime,
|
||||
canChangeDate,
|
||||
priceType,
|
||||
} = bookedRoom
|
||||
|
||||
const datetimeIsInThePast = isDatetimePast(checkInDate)
|
||||
|
||||
const isDateModifyable = checkDateModifiable(
|
||||
canChangeDate,
|
||||
datetimeIsInThePast,
|
||||
bookedRoom.isCancelled,
|
||||
priceType === "points"
|
||||
)
|
||||
|
||||
const isCancelable = checkCancelable(
|
||||
bookedRoom.isCancelable,
|
||||
datetimeIsInThePast,
|
||||
linkedReservationRooms
|
||||
)
|
||||
|
||||
const isGuaranteeable = checkGuaranteeable(
|
||||
!!bookedRoom.guaranteeInfo,
|
||||
bookedRoom.isCancelled,
|
||||
datetimeIsInThePast
|
||||
)
|
||||
|
||||
const canDownloadInvoice = checkCanDownloadInvoice(
|
||||
bookedRoom.isCancelled,
|
||||
bookedRoom.rateDefinition.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM
|
||||
)
|
||||
|
||||
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 handleModifyStay = () => {
|
||||
trackMyStayPageLink("modify dates")
|
||||
setActiveView("modifyStay")
|
||||
}
|
||||
|
||||
const handleCancelStay = () => {
|
||||
trackMyStayPageLink("cancel booking")
|
||||
setActiveView("cancelStay")
|
||||
}
|
||||
|
||||
const handleDownloadInvoice = () => {
|
||||
trackMyStayPageLink("download invoice")
|
||||
}
|
||||
|
||||
const handleGuaranteeLateArrival = () => {
|
||||
trackMyStayPageLink("guarantee late arrival")
|
||||
setActiveView("guaranteeLateArrival")
|
||||
}
|
||||
|
||||
const handleCustomerSupport = () => {
|
||||
trackMyStayPageLink("customer support")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.actionPanel}>
|
||||
<div className={styles.menu}>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={handleModifyStay}
|
||||
intent="text"
|
||||
className={styles.button}
|
||||
disabled={!isDateModifyable}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify dates",
|
||||
})}
|
||||
<MaterialIcon icon="calendar_month" color="CurrentColor" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={handleGuaranteeLateArrival}
|
||||
intent="text"
|
||||
className={styles.button}
|
||||
disabled={!isGuaranteeable}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Guarantee late arrival",
|
||||
})}
|
||||
<MaterialIcon icon="credit_card" color="CurrentColor" />
|
||||
</Button>
|
||||
|
||||
<AddToCalendar
|
||||
checkInDate={checkInDate}
|
||||
event={calendarEvent}
|
||||
hotelName={hotel.name}
|
||||
renderButton={(onPress) => (
|
||||
<AddToCalendarButton
|
||||
onPress={onPress}
|
||||
disabled={datetimeIsInThePast}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{canDownloadInvoice ? (
|
||||
<Link
|
||||
href={preliminaryReceipt[lang]}
|
||||
target="_blank"
|
||||
keepSearchParams
|
||||
className={styles.actionLink}
|
||||
onClick={handleDownloadInvoice}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Download invoice",
|
||||
})}
|
||||
<MaterialIcon icon="download" color="CurrentColor" />
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.disabledLink}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Download invoice",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
|
||||
<MaterialIcon icon="download" color="CurrentColor" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={handleCancelStay}
|
||||
intent="text"
|
||||
className={styles.button}
|
||||
disabled={!isCancelable}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Cancel stay",
|
||||
})}
|
||||
<MaterialIcon icon="cancel" color="CurrentColor" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div>
|
||||
<span className={styles.tag}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Reference number",
|
||||
})}
|
||||
</span>
|
||||
<Subtitle color="burgundy" textAlign="right">
|
||||
{confirmationNumber}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast" textAlign="right">
|
||||
{hotel.name}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast" textAlign="right">
|
||||
{hotel.address.streetAddress}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast" textAlign="right">
|
||||
{hotel.address.city}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast" asChild>
|
||||
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
<Link
|
||||
href={customerService[lang]}
|
||||
variant="icon"
|
||||
className={styles.link}
|
||||
onClick={handleCustomerSupport}
|
||||
>
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Customer support",
|
||||
})}
|
||||
</Caption>
|
||||
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
export function isDatetimePast(date: Date) {
|
||||
return dt(date).hour(18).minute(0).second(0).isBefore(dt(), "seconds")
|
||||
}
|
||||
|
||||
export function checkDateModifiable(
|
||||
canChangeDate: boolean,
|
||||
datetimeIsInThePast: boolean,
|
||||
isCancelled: boolean,
|
||||
isRewardNight: boolean
|
||||
) {
|
||||
return canChangeDate && !datetimeIsInThePast && !isCancelled && !isRewardNight
|
||||
}
|
||||
|
||||
export function checkCancelable(
|
||||
isCancelable: boolean,
|
||||
datetimeIsInThePast: boolean,
|
||||
linkedReservationRooms: Room[]
|
||||
) {
|
||||
const hasAnyCancelableRoom =
|
||||
isCancelable || linkedReservationRooms.some((room) => room.isCancelable)
|
||||
|
||||
return hasAnyCancelableRoom && !datetimeIsInThePast
|
||||
}
|
||||
|
||||
export function checkGuaranteeable(
|
||||
guaranteeInfo: boolean,
|
||||
isCancelled: boolean,
|
||||
datetimeIsInThePast: boolean
|
||||
) {
|
||||
return !guaranteeInfo && !isCancelled && !datetimeIsInThePast
|
||||
}
|
||||
|
||||
export function checkCanDownloadInvoice(
|
||||
isCancelled: boolean,
|
||||
isFlexBooking: boolean
|
||||
) {
|
||||
return !isCancelled && !isFlexBooking
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import { useManageStayStore } from "@/stores/my-stay/manageStayStore"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import GuaranteeLateArrival from "../GuaranteeLateArrival"
|
||||
import CancelStay from "./ActionPanel/Actions/CancelStay"
|
||||
import ModifyStay from "./ActionPanel/Actions/ModifyStay"
|
||||
import ActionPanel from "./ActionPanel"
|
||||
|
||||
import styles from "./manangeStay.module.css"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import { type CreditCard } from "@/types/user"
|
||||
|
||||
interface ManageStayProps {
|
||||
hotel: Hotel
|
||||
savedCreditCards: CreditCard[] | null
|
||||
refId: string
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export default function ManageStay({
|
||||
hotel,
|
||||
savedCreditCards,
|
||||
refId,
|
||||
isLoggedIn,
|
||||
}: ManageStayProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
isOpen,
|
||||
activeView,
|
||||
actions: { setIsOpen, handleCloseModal },
|
||||
} = useManageStayStore()
|
||||
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const allRoomsCancelled =
|
||||
linkedReservationRooms.every((room) => room.isCancelled) &&
|
||||
bookedRoom.isCancelled
|
||||
|
||||
function renderContent() {
|
||||
switch (activeView) {
|
||||
case "cancelStay":
|
||||
return <CancelStay hotel={hotel} />
|
||||
case "modifyStay":
|
||||
return <ModifyStay isLoggedIn={isLoggedIn} />
|
||||
case "guaranteeLateArrival":
|
||||
return (
|
||||
<GuaranteeLateArrival
|
||||
savedCreditCards={savedCreditCards}
|
||||
refId={refId}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <ActionPanel hotel={hotel} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
fullWidth
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="small"
|
||||
disabled={allRoomsCancelled}
|
||||
className={styles.manageStayButton}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Manage stay",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
color={
|
||||
allRoomsCancelled ? "Icon/Interactive/Disabled" : "Icon/Inverted"
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onToggle={handleCloseModal}
|
||||
withActions
|
||||
hideHeader
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
button.manageStayButton {
|
||||
color: var(--Text-Inverted);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Cheques from "../Cheques"
|
||||
import Points from "../Points"
|
||||
import Price from "../Price"
|
||||
|
||||
import { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface PriceTypeProps
|
||||
extends Pick<
|
||||
BookingConfirmation["booking"],
|
||||
"cheques" | "rateDefinition" | "roomPoints" | "totalPrice" | "vouchers"
|
||||
> {
|
||||
isCancelled: boolean
|
||||
priceType: PriceTypeEnum
|
||||
}
|
||||
|
||||
export default function PriceType({
|
||||
cheques,
|
||||
isCancelled,
|
||||
priceType,
|
||||
rateDefinition,
|
||||
roomPoints,
|
||||
totalPrice,
|
||||
vouchers,
|
||||
}: PriceTypeProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
switch (priceType) {
|
||||
case PriceTypeEnum.cheque:
|
||||
return <Cheques cheques={cheques} price={isCancelled ? 0 : totalPrice} />
|
||||
case PriceTypeEnum.money:
|
||||
return (
|
||||
<Price
|
||||
isMember={rateDefinition.isMemberRate}
|
||||
price={isCancelled ? 0 : totalPrice}
|
||||
variant="Title/Subtitle/lg"
|
||||
/>
|
||||
)
|
||||
case PriceTypeEnum.points:
|
||||
return <Points points={roomPoints} variant="Title/Subtitle/lg" />
|
||||
case PriceTypeEnum.voucher:
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{count} voucher",
|
||||
},
|
||||
{ count: vouchers }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import type { Variant } from "../Rooms/TotalPrice"
|
||||
|
||||
export default function Points({
|
||||
points,
|
||||
variant,
|
||||
}: {
|
||||
points: number | null
|
||||
variant: Variant
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (points === null) {
|
||||
return <SkeletonShimmer width={"100px"} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant={variant}>
|
||||
<p>
|
||||
{intl.formatNumber(points)}
|
||||
{
|
||||
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
|
||||
" "
|
||||
}
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Points",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./price.module.css"
|
||||
|
||||
import type { Variant } from "../Rooms/TotalPrice"
|
||||
|
||||
export default function Price({
|
||||
price,
|
||||
variant,
|
||||
isMember,
|
||||
}: {
|
||||
price: number | null
|
||||
variant: Variant
|
||||
isMember?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const currencyCode = useMyStayTotalPriceStore((state) => state.currencyCode)
|
||||
|
||||
if (price === null) {
|
||||
return <SkeletonShimmer width={"100px"} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant={variant}>
|
||||
<p className={isMember ? styles.memberPrice : styles.nonMemberPrice}>
|
||||
{formatPrice(intl, price, currencyCode)}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
|
||||
@@ -9,19 +9,17 @@ import { calculateTotalPrice, mapToPrice } from "./mapToPrice"
|
||||
import styles from "./priceDetails.module.css"
|
||||
|
||||
export default function PriceDetails() {
|
||||
const bookedRoom = useMyStayRoomDetailsStore((state) => state.bookedRoom)
|
||||
const linkedReservationRooms = useMyStayRoomDetailsStore(
|
||||
(state) => state.linkedReservationRooms
|
||||
)
|
||||
|
||||
const rooms = [bookedRoom, ...linkedReservationRooms]
|
||||
.filter((room) => !room.isCancelled)
|
||||
.map((room) => ({
|
||||
...room,
|
||||
breakfastIncluded: room.rateDefinition.breakfastIncluded,
|
||||
price: mapToPrice(room),
|
||||
roomType: room.roomName,
|
||||
}))
|
||||
const { bookedRoom, rooms } = useMyStayStore((state) => ({
|
||||
bookedRoom: state.bookedRoom,
|
||||
rooms: state.rooms
|
||||
.filter((room) => !room.isCancelled)
|
||||
.map((room) => ({
|
||||
...room,
|
||||
breakfastIncluded: room.rateDefinition.breakfastIncluded,
|
||||
price: mapToPrice(room),
|
||||
roomType: room.roomName,
|
||||
})),
|
||||
}))
|
||||
|
||||
const bookingCode =
|
||||
rooms.find((room) => room.bookingCode)?.bookingCode ?? undefined
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dt } from "@/lib/dt"
|
||||
import { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
|
||||
export function mapToPrice(room: Room) {
|
||||
switch (room.priceType) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayTotalPriceStore } from "@/stores/my-stay/myStayTotalPrice"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
@@ -12,16 +12,18 @@ import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function Cheques({
|
||||
cheques,
|
||||
isCancelled,
|
||||
price,
|
||||
}: {
|
||||
cheques: number
|
||||
isCancelled: boolean
|
||||
price: number
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const currencyCode = useMyStayTotalPriceStore((state) => state.currencyCode)
|
||||
const currency = useMyStayStore((state) => state.bookedRoom.currencyCode)
|
||||
|
||||
if (!cheques) {
|
||||
return <SkeletonShimmer width={"100px"} />
|
||||
return <SkeletonShimmer width="100px" />
|
||||
}
|
||||
|
||||
const totalPrice = formatPrice(
|
||||
@@ -29,12 +31,12 @@ export default function Cheques({
|
||||
cheques,
|
||||
CurrencyEnum.CC,
|
||||
price,
|
||||
currencyCode
|
||||
currency
|
||||
)
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p>{totalPrice}</p>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>{isCancelled ? <s>{totalPrice}</s> : totalPrice}</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function Points({
|
||||
isCancelled,
|
||||
points,
|
||||
price,
|
||||
}: {
|
||||
isCancelled: boolean
|
||||
points: number
|
||||
price: number
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const currency = useMyStayStore((state) => state.bookedRoom.currencyCode)
|
||||
|
||||
if (!points) {
|
||||
return <SkeletonShimmer width="100px" />
|
||||
}
|
||||
|
||||
const totalPrice = formatPrice(
|
||||
intl,
|
||||
points,
|
||||
CurrencyEnum.POINTS,
|
||||
price,
|
||||
currency
|
||||
)
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>{isCancelled ? <s>{totalPrice}</s> : totalPrice}</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./price.module.css"
|
||||
|
||||
export default function Price({
|
||||
isCancelled,
|
||||
isMember,
|
||||
price,
|
||||
}: {
|
||||
isCancelled: boolean
|
||||
isMember?: boolean
|
||||
price: string
|
||||
}) {
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p className={isMember ? styles.memberPrice : styles.nonMemberPrice}>
|
||||
{isCancelled ? <s>{price}</s> : price}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function Vouchers({
|
||||
isCancelled,
|
||||
price,
|
||||
vouchers,
|
||||
}: {
|
||||
isCancelled: boolean
|
||||
price?: number
|
||||
vouchers: number
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const currency = useMyStayStore((state) => state.bookedRoom.currencyCode)
|
||||
|
||||
if (!vouchers) {
|
||||
return <SkeletonShimmer width="100px" />
|
||||
}
|
||||
|
||||
const totalPrice = formatPrice(
|
||||
intl,
|
||||
vouchers,
|
||||
CurrencyEnum.Voucher,
|
||||
price,
|
||||
currency
|
||||
)
|
||||
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>{isCancelled ? <s>{totalPrice}</s> : totalPrice}</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Cheques from "./Cheques"
|
||||
import Points from "./Points"
|
||||
import Price from "./Price"
|
||||
import Vouchers from "./Vouchers"
|
||||
|
||||
import { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
@@ -15,12 +13,14 @@ interface PriceTypeProps
|
||||
BookingConfirmation["booking"],
|
||||
"cheques" | "rateDefinition" | "roomPoints" | "totalPrice" | "vouchers"
|
||||
> {
|
||||
formattedTotalPrice: string
|
||||
isCancelled: boolean
|
||||
priceType: PriceTypeEnum
|
||||
}
|
||||
|
||||
export default function PriceType({
|
||||
cheques,
|
||||
formattedTotalPrice,
|
||||
isCancelled,
|
||||
priceType,
|
||||
rateDefinition,
|
||||
@@ -28,33 +28,38 @@ export default function PriceType({
|
||||
totalPrice,
|
||||
vouchers,
|
||||
}: PriceTypeProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
switch (priceType) {
|
||||
case PriceTypeEnum.cheque:
|
||||
return <Cheques cheques={cheques} price={isCancelled ? 0 : totalPrice} />
|
||||
return (
|
||||
<Cheques
|
||||
cheques={cheques}
|
||||
isCancelled={isCancelled}
|
||||
price={totalPrice}
|
||||
/>
|
||||
)
|
||||
case PriceTypeEnum.money:
|
||||
return (
|
||||
<Price
|
||||
isCancelled={isCancelled}
|
||||
isMember={rateDefinition.isMemberRate}
|
||||
price={isCancelled ? 0 : totalPrice}
|
||||
variant="Title/Subtitle/lg"
|
||||
price={formattedTotalPrice}
|
||||
/>
|
||||
)
|
||||
case PriceTypeEnum.points:
|
||||
return <Points points={roomPoints} variant="Title/Subtitle/lg" />
|
||||
return (
|
||||
<Points
|
||||
isCancelled={isCancelled}
|
||||
points={roomPoints}
|
||||
price={totalPrice}
|
||||
/>
|
||||
)
|
||||
case PriceTypeEnum.voucher:
|
||||
return (
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{count} voucher",
|
||||
},
|
||||
{ count: vouchers }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<Vouchers
|
||||
isCancelled={isCancelled}
|
||||
price={totalPrice}
|
||||
vouchers={vouchers}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
@@ -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,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",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { da, de, fi, nb, sv } from "date-fns/locale"
|
||||
import { useEffect, useState } from "react"
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { 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 Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
@@ -20,55 +19,33 @@ import styles from "./newDates.module.css"
|
||||
|
||||
import type { DateRange } from "react-day-picker"
|
||||
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import type { Room } from "@/stores/my-stay/myStayRoomDetailsStore"
|
||||
export default function NewDates() {
|
||||
const { checkInDate, checkOutDate } = useMyStayStore((state) => ({
|
||||
checkInDate: state.mainRoom.checkInDate,
|
||||
checkOutDate: state.mainRoom.checkOutDate,
|
||||
}))
|
||||
|
||||
const locales = {
|
||||
[Lang.da]: da,
|
||||
[Lang.de]: de,
|
||||
[Lang.fi]: fi,
|
||||
[Lang.no]: nb,
|
||||
[Lang.sv]: sv,
|
||||
}
|
||||
|
||||
interface NewDatesProps {
|
||||
mainRoom: Room
|
||||
noAvailability: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export default function NewDates({
|
||||
mainRoom,
|
||||
noAvailability,
|
||||
error,
|
||||
}: NewDatesProps) {
|
||||
const [showCheckInDatePicker, setShowCheckInDatePicker] = useState(false)
|
||||
const [showCheckOutDatePicker, setShowCheckOutDatePicker] = useState(false)
|
||||
const [selectedDates, setSelectedDates] = useState<DateRange>(() => ({
|
||||
from: dt(mainRoom.checkInDate).startOf("day").toDate(),
|
||||
to: dt(mainRoom.checkOutDate).startOf("day").toDate(),
|
||||
from: dt(checkInDate).startOf("day").toDate(),
|
||||
to: dt(checkOutDate).startOf("day").toDate(),
|
||||
}))
|
||||
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
// Initialize form values on mount
|
||||
useEffect(() => {
|
||||
setValue("checkInDate", dt(mainRoom.checkInDate).format("YYYY-MM-DD"))
|
||||
setValue("checkOutDate", dt(mainRoom.checkOutDate).format("YYYY-MM-DD"))
|
||||
}, [mainRoom.checkInDate, mainRoom.checkOutDate, setValue])
|
||||
|
||||
// Calculate default number of days between check-in and check-out
|
||||
const defaultDaysBetween = dt(mainRoom.checkOutDate)
|
||||
const defaultDaysBetween = dt(checkOutDate)
|
||||
.startOf("day")
|
||||
.diff(dt(mainRoom.checkInDate).startOf("day"), "days")
|
||||
.diff(dt(checkInDate).startOf("day"), "days")
|
||||
|
||||
function showCheckInPicker() {
|
||||
// Update selected dates before showing picker
|
||||
setSelectedDates((prev) => ({
|
||||
from: prev.from ?? dt(mainRoom.checkInDate).startOf("day").toDate(),
|
||||
to: prev.to ?? dt(mainRoom.checkOutDate).startOf("day").toDate(),
|
||||
from: prev.from ?? dt(checkInDate).startOf("day").toDate(),
|
||||
to: prev.to ?? dt(checkOutDate).startOf("day").toDate(),
|
||||
}))
|
||||
setShowCheckInDatePicker(true)
|
||||
setShowCheckOutDatePicker(false)
|
||||
@@ -77,8 +54,8 @@ export default function NewDates({
|
||||
function showCheckOutPicker() {
|
||||
// Update selected dates before showing picker
|
||||
setSelectedDates((prev) => ({
|
||||
from: prev.from ?? dt(mainRoom.checkInDate).startOf("day").toDate(),
|
||||
to: prev.to ?? dt(mainRoom.checkOutDate).startOf("day").toDate(),
|
||||
from: prev.from ?? dt(checkInDate).startOf("day").toDate(),
|
||||
to: prev.to ?? dt(checkOutDate).startOf("day").toDate(),
|
||||
}))
|
||||
setShowCheckOutDatePicker(true)
|
||||
setShowCheckInDatePicker(false)
|
||||
@@ -126,30 +103,11 @@ export default function NewDates({
|
||||
setValue("checkOutDate", newCheckOut.format("YYYY-MM-DD"))
|
||||
}
|
||||
|
||||
const fromDate = selectedDates.from ?? dt(checkInDate).toDate()
|
||||
const toDate = selectedDates.to ?? dt(checkOutDate).toDate()
|
||||
|
||||
return (
|
||||
<>
|
||||
{noAvailability && (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "No availability",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage: "No single rooms are available on these dates",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Alert
|
||||
type={AlertTypeEnum.Alarm}
|
||||
heading={intl.formatMessage({
|
||||
defaultMessage: "Error",
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
defaultMessage: "Something went wrong!",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.checkInDate}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
@@ -190,21 +148,13 @@ export default function NewDates({
|
||||
<DatePickerSingleDesktop
|
||||
close={() => setShowCheckInDatePicker(false)}
|
||||
handleOnSelect={handleCheckInDateSelect}
|
||||
locales={locales}
|
||||
selectedDate={
|
||||
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
|
||||
}
|
||||
startMonth={
|
||||
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
|
||||
}
|
||||
selectedDate={fromDate}
|
||||
startMonth={fromDate}
|
||||
/>
|
||||
<DatePickerSingleMobile
|
||||
close={() => setShowCheckInDatePicker(false)}
|
||||
handleOnSelect={handleCheckInDateSelect}
|
||||
locales={locales}
|
||||
selectedDate={
|
||||
selectedDates.from ?? dt(mainRoom.checkInDate).toDate()
|
||||
}
|
||||
selectedDate={fromDate}
|
||||
hideHeader
|
||||
/>
|
||||
</Modal>,
|
||||
@@ -220,21 +170,13 @@ export default function NewDates({
|
||||
<DatePickerSingleDesktop
|
||||
close={() => setShowCheckOutDatePicker(false)}
|
||||
handleOnSelect={handleCheckOutDateSelect}
|
||||
locales={locales}
|
||||
selectedDate={
|
||||
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
|
||||
}
|
||||
startMonth={
|
||||
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
|
||||
}
|
||||
selectedDate={toDate}
|
||||
startMonth={toDate}
|
||||
/>
|
||||
<DatePickerSingleMobile
|
||||
close={() => setShowCheckOutDatePicker(false)}
|
||||
handleOnSelect={handleCheckOutDateSelect}
|
||||
locales={locales}
|
||||
selectedDate={
|
||||
selectedDates.to ?? dt(mainRoom.checkOutDate).toDate()
|
||||
}
|
||||
selectedDate={toDate}
|
||||
hideHeader
|
||||
/>
|
||||
</Modal>,
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,3 @@
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
|
||||
.addCreditCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guaranteeCost {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--Spacing-x2);
|
||||
align-items: flex-end;
|
||||
gap: var(--Spacing-x3);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
}
|
||||
|
||||
.guaranteeCostText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.termsAndConditions {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -56,3 +7,42 @@
|
||||
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,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")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user