feat: refactor of my stay

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
button.manageStayButton {
color: var(--Text-Inverted);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
"use client"
import { useMyStayStore } from "@/stores/my-stay"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
import { dateHasPassed } from "../utils"
import AddToCalendarButton from "./AddToCalendarButton"
import type { EventAttributes } from "ics"
export default function AddToCalendarAction() {
const { checkInDate, checkOutDate, createDateTime, hotel } = useMyStayStore(
(state) => ({
checkInDate: state.bookedRoom.checkInDate,
checkOutDate: state.bookedRoom.checkOutDate,
createDateTime: state.bookedRoom.createDateTime,
hotel: state.hotel,
})
)
const calendarEvent: EventAttributes = {
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(createDateTime),
description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(checkOutDate),
endInputType: "utc",
geo: {
lat: hotel.location.latitude,
lon: hotel.location.longitude,
},
location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`,
start: generateDateTime(checkInDate),
startInputType: "utc",
status: "CONFIRMED",
title: hotel.name,
url: hotel.contactInformation.websiteUrl,
}
const disabled = dateHasPassed(
checkInDate,
hotel.hotelFacts.checkin.checkInTime
)
return (
<AddToCalendar
checkInDate={checkInDate}
event={calendarEvent}
hotelName={hotel.name}
renderButton={(onPress) => (
<AddToCalendarButton disabled={disabled} onPress={onPress} />
)}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More