Merged in chore/move-enter-details (pull request #2778)
Chore/move enter details Approved-by: Anton Gunnarsson
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Form from "@/components/Forms/Edit/Profile"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import Profile from "@/components/MyPages/Profile"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/booking"
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/paymentCallbackStatusEnum"
|
||||
import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/booking"
|
||||
import { HandleErrorCallback } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
|
||||
import { HandleSuccessCallback } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback"
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/paymentCallbackStatusEnum"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
details,
|
||||
@@ -15,8 +17,6 @@ import { isValidSession } from "@scandic-hotels/trpc/utils/session"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
|
||||
import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AlternativeHotelsMapPage as AlternativeHotelsMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsMapPage"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
@@ -18,12 +17,6 @@ export default async function AlternativeHotelsMapPage(
|
||||
<AlternativeHotelsMapPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AlternativeHotelsPage as AlternativeHotelsPagePrimitive } from "@scandic-hotels/booking-flow/pages/AlternativeHotelsPage"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
@@ -14,17 +13,7 @@ export default async function AlternativeHotelsPage(
|
||||
) {
|
||||
const searchParams = await props.searchParams
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<AlternativeHotelsPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<AlternativeHotelsPagePrimitive lang={lang} searchParams={searchParams} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: var(--max-width-page);
|
||||
margin: var(--Spacing-x3) auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
width: var(--max-width-page);
|
||||
margin: var(--Spacing-x5) auto 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
margin: var(--Spacing-x3) 0 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/-1;
|
||||
z-index: unset;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,12 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import FnFNotAllowedAlert from "@scandic-hotels/booking-flow/components/FnFNotAllowedAlert"
|
||||
import { parseDetailsSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { FamilyAndFriendsCodes } from "@scandic-hotels/common/constants/familyAndFriends"
|
||||
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getHotel,
|
||||
getProfileSafely,
|
||||
getSelectedRoomsAvailabilityEnterDetails,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import Multiroom from "@/components/HotelReservation/EnterDetails/Room/Multiroom"
|
||||
import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
|
||||
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
|
||||
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
|
||||
import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDetails/Tracking"
|
||||
import RoomProvider from "@/providers/Details/RoomProvider"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
import { EnterDetailsPage as EnterDetailsPagePrimitive } from "@scandic-hotels/booking-flow/pages/EnterDetailsPage"
|
||||
|
||||
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function DetailsPage(
|
||||
props: PageArgs<LangParams, NextSearchParams>
|
||||
) {
|
||||
const { lang } = await props.params
|
||||
const searchParams = await props.searchParams
|
||||
const params = await props.params
|
||||
|
||||
const { lang } = params
|
||||
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
|
||||
const booking = parseDetailsSearchParams(searchParams)
|
||||
|
||||
if (!booking) return notFound()
|
||||
|
||||
if (selectRoomParams.has("activeRoomIndex")) {
|
||||
selectRoomParams.delete("activeRoomIndex")
|
||||
}
|
||||
|
||||
if (
|
||||
booking.bookingCode &&
|
||||
FamilyAndFriendsCodes.includes(booking.bookingCode)
|
||||
) {
|
||||
const cookieStore = await cookies()
|
||||
const isInValidFNF = cookieStore.get("sc")?.value !== "1"
|
||||
|
||||
if (isInValidFNF) {
|
||||
return <FnFNotAllowedAlert />
|
||||
}
|
||||
}
|
||||
|
||||
const breakfastInput = {
|
||||
adults: 1,
|
||||
fromDate: booking.fromDate,
|
||||
hotelId: booking.hotelId,
|
||||
toDate: booking.toDate,
|
||||
}
|
||||
const hotelInput = {
|
||||
hotelId: booking.hotelId,
|
||||
// TODO: Remove this from input since it forces
|
||||
// waterfalls for no other reason than to
|
||||
// set merchantInformationData.alternatePaymentOptions
|
||||
// to an empty array
|
||||
isCardOnlyPayment: false,
|
||||
language: lang,
|
||||
}
|
||||
void getHotel(hotelInput)
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getProfileSafely()
|
||||
|
||||
const rooms = await getSelectedRoomsAvailabilityEnterDetails({
|
||||
booking,
|
||||
lang,
|
||||
})
|
||||
|
||||
const hotelData = await getHotel(hotelInput)
|
||||
|
||||
if (!hotelData || !rooms.length) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
|
||||
const isCardOnlyPayment = rooms.some((room) => room.mustBeGuaranteed)
|
||||
const { hotel } = hotelData
|
||||
// TODO: Temp fix to avoid waterfall fetch and moving this
|
||||
// logic from the route here for now
|
||||
if (isCardOnlyPayment) {
|
||||
hotel.merchantInformationData.alternatePaymentOptions = []
|
||||
}
|
||||
|
||||
const firstRoom = rooms[0]
|
||||
const multirooms = rooms.slice(1)
|
||||
|
||||
const { rateDefinition, rateDefinitionMember } = firstRoom.roomRate
|
||||
if (user && rateDefinitionMember) {
|
||||
const rateCode = selectRoomParams.get("room[0].ratecode")
|
||||
if (rateDefinitionMember.rateCode !== rateCode) {
|
||||
booking.rooms[0].rateCode = rateDefinitionMember.rateCode
|
||||
selectRoomParams.set("room[0].ratecode", rateDefinitionMember.rateCode)
|
||||
booking.rooms[0].counterRateCode = rateDefinition.rateCode
|
||||
selectRoomParams.set("room[0].counterratecode", rateDefinition.rateCode)
|
||||
}
|
||||
}
|
||||
|
||||
// attribute data-footer-spacing used to add spacing
|
||||
// beneath footer to be able to show entire footer upon
|
||||
// scrolling down to the bottom of the page
|
||||
return (
|
||||
<main data-footer-spacing>
|
||||
<EnterDetailsProvider
|
||||
booking={booking}
|
||||
breakfastPackages={breakfastPackages}
|
||||
lang={lang}
|
||||
rooms={rooms}
|
||||
searchParamsStr={selectRoomParams.toString()}
|
||||
user={user}
|
||||
vat={hotel.vat}
|
||||
roomCategories={hotelData.roomCategories}
|
||||
>
|
||||
<HotelHeader hotelData={hotelData} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<RoomProvider idx={0} room={firstRoom}>
|
||||
<RoomOne user={user} />
|
||||
</RoomProvider>
|
||||
{multirooms.map((room, idx) => (
|
||||
// Need to start idx from 1 since first room is
|
||||
// rendered above
|
||||
<RoomProvider key={idx + 1} idx={idx + 1} room={room}>
|
||||
<Multiroom />
|
||||
</RoomProvider>
|
||||
))}
|
||||
<Suspense>
|
||||
<Payment
|
||||
otherPaymentOptions={
|
||||
hotel.merchantInformationData.alternatePaymentOptions
|
||||
}
|
||||
supportedCards={hotel.merchantInformationData.cards}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<aside className={styles.summary}>
|
||||
<MobileSummary isUserLoggedIn={!!user} />
|
||||
<DesktopSummary isUserLoggedIn={!!user} />
|
||||
</aside>
|
||||
</div>
|
||||
<EnterDetailsTrackingWrapper
|
||||
booking={booking}
|
||||
hotel={hotel}
|
||||
isMember={!!user}
|
||||
lang={lang}
|
||||
rooms={rooms}
|
||||
/>
|
||||
</EnterDetailsProvider>
|
||||
</main>
|
||||
)
|
||||
return <EnterDetailsPagePrimitive lang={lang} searchParams={searchParams} />
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SelectHotelMapPage as SelectHotelMapPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelMapPage"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
@@ -15,16 +14,7 @@ export default async function SelectHotelMapPage(
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<SelectHotelMapPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SelectHotelMapPagePrimitive lang={lang} searchParams={searchParams} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SelectHotelPage as SelectHotelPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelPage"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { LangParams, NextSearchParams, PageArgs } from "@/types/params"
|
||||
@@ -11,16 +10,5 @@ export default async function SelectHotelPage(
|
||||
const searchParams = await props.searchParams
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<SelectHotelPagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return <SelectHotelPagePrimitive lang={lang} searchParams={searchParams} />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SelectRatePage as SelectRatePagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectRatePage"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
@@ -15,16 +14,5 @@ export default async function SelectRatePage(
|
||||
const searchParams = await props.searchParams
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<SelectRatePagePrimitive
|
||||
lang={lang}
|
||||
searchParams={searchParams}
|
||||
renderTracking={(props) => (
|
||||
<TrackingSDK
|
||||
hotelInfo={props.hotelsTrackingData}
|
||||
pageData={props.pageTrackingData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return <SelectRatePagePrimitive lang={lang} searchParams={searchParams} />
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
export default async function Tracking() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||
import Script from "next/script"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
import StorageCleaner from "@scandic-hotels/booking-flow/components/EnterDetails/StorageCleaner"
|
||||
import { NuqsAdapter } from "@scandic-hotels/booking-flow/utils/nuqs"
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { ToastHandler } from "@scandic-hotels/design-system/ToastHandler"
|
||||
@@ -19,7 +20,6 @@ import { BookingFlowProviders } from "@/components/BookingFlowProviders"
|
||||
import CookieBotConsent from "@/components/CookieBot"
|
||||
import Footer from "@/components/Footer"
|
||||
import Header from "@/components/Header"
|
||||
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
|
||||
import { RACRouterProvider } from "@/components/RACRouterProvider"
|
||||
import RouteChange from "@/components/RouteChange"
|
||||
import SitewideAlert from "@/components/SitewideAlert"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||
import Script from "next/script"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
import StorageCleaner from "@scandic-hotels/booking-flow/components/EnterDetails/StorageCleaner"
|
||||
import { NuqsAdapter } from "@scandic-hotels/booking-flow/utils/nuqs"
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { ToastHandler } from "@scandic-hotels/design-system/ToastHandler"
|
||||
@@ -16,7 +17,6 @@ import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
|
||||
import CookieBotConsent from "@/components/CookieBot"
|
||||
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
|
||||
import RouteChange from "@/components/RouteChange"
|
||||
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
|
||||
import GTMScript from "@/components/TrackingSDK/GTMScript"
|
||||
|
||||
@@ -7,6 +7,7 @@ import "@scandic-hotels/design-system/style.css"
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||
import Script from "next/script"
|
||||
|
||||
import StorageCleaner from "@scandic-hotels/booking-flow/components/EnterDetails/StorageCleaner"
|
||||
import { NuqsAdapter } from "@scandic-hotels/booking-flow/utils/nuqs"
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { ToastHandler } from "@scandic-hotels/design-system/ToastHandler"
|
||||
@@ -15,7 +16,6 @@ import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||
import CookieBotConsent from "@/components/CookieBot"
|
||||
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
|
||||
import RouteChange from "@/components/RouteChange"
|
||||
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
|
||||
import GTMScript from "@/components/TrackingSDK/GTMScript"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
export default async function Tracking() {
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
|
||||
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
|
||||
import { BookingFlowTrackingProvider } from "@scandic-hotels/booking-flow/BookingFlowTrackingProvider"
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
import { trackAccordionClick, trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||
import { trackBookingSearchClick } from "@/utils/tracking/booking"
|
||||
import {
|
||||
trackAccordionClick,
|
||||
trackLoginClick,
|
||||
trackOpenSidePeekEvent,
|
||||
trackPaymentEvent,
|
||||
trackUpdatePaymentMethod,
|
||||
} from "@/utils/tracking"
|
||||
import {
|
||||
trackBedSelection,
|
||||
trackBookingSearchClick,
|
||||
trackBreakfastSelection,
|
||||
} from "@/utils/tracking/booking"
|
||||
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
@@ -21,6 +32,12 @@ export function BookingFlowProviders({ children }: { children: ReactNode }) {
|
||||
trackAccordionItemOpen: trackAccordionClick,
|
||||
trackOpenSidePeek: trackOpenSidePeekEvent,
|
||||
trackGenericEvent: trackEvent,
|
||||
trackGlaSaveCardAttempt,
|
||||
trackLoginClick,
|
||||
trackPaymentEvent,
|
||||
trackUpdatePaymentMethod,
|
||||
trackBreakfastSelection,
|
||||
trackBedSelection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { getCampaignOverviewPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import TopCampaign from "@/components/ContentType/CampaignOverviewPage/TopCampaign"
|
||||
import LinkChips from "@/components/TempDesignSystem/LinkChips"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import Blocks from "./Blocks"
|
||||
import CampaignOverviewPageSkeleton from "./CampaignOverviewPageSkeleton"
|
||||
|
||||
@@ -3,11 +3,10 @@ import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { getCampaignPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import Blocks from "./Blocks"
|
||||
import CampaignPageSkeleton from "./CampaignPageSkeleton"
|
||||
import CampaignHero from "./Hero"
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Suspense } from "react"
|
||||
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { getDestinationOverviewPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import { DestinationSearch } from "./DestinationSearch"
|
||||
import HotelsSection from "./HotelsSection"
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { trackOpenMapView } from "@/utils/tracking/destinationPage"
|
||||
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/common/tracking/types"
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/tracking/types"
|
||||
|
||||
interface DestinationTrackingProps {
|
||||
pageData: TrackingSDKPageData
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dt } from "@scandic-hotels/common/dt"
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import {
|
||||
getHotel,
|
||||
@@ -16,7 +17,6 @@ import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||
import HotelCampaigns from "@/components/ContentType/HotelPage/Campaigns"
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { setFacilityCards } from "@/utils/facilityCards"
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
TrackingChannelEnum,
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
import { PointOfInterestGroupEnum } from "@scandic-hotels/trpc/enums/pointOfInterest"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from "react"
|
||||
|
||||
import Preamble from "@scandic-hotels/design-system/Preamble"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
@@ -11,7 +12,6 @@ import Hero from "@/components/Hero"
|
||||
import MaxWidth from "@/components/MaxWidth"
|
||||
import Sidebar from "@/components/Sidebar"
|
||||
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { isLoggedInUser } from "@/utils/isLoggedInUser"
|
||||
|
||||
import styles from "./loyaltyPage.module.css"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { FloatingBookingWidget } from "@scandic-hotels/booking-flow/BookingWidget/FloatingBookingWidget"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
import { BlocksEnums } from "@scandic-hotels/trpc/types/blocksEnum"
|
||||
|
||||
import { getStartPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import FullWidthCampaign from "@/components/Blocks/FullWidthCampaign"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./startPage.module.css"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from "react"
|
||||
|
||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import HeaderDynamicContent from "@/components/Headers/DynamicContent"
|
||||
@@ -10,7 +11,6 @@ import MeetingPackageWidget from "@/components/MeetingPackageWidget"
|
||||
import Sidebar from "@/components/Sidebar"
|
||||
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
|
||||
import LinkChips from "@/components/TempDesignSystem/LinkChips"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import { staticPageVariants } from "./variants"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/common/tracking/types"
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/tracking/types"
|
||||
import type { CollectionPage } from "@scandic-hotels/trpc/types/collectionPage"
|
||||
import type { ContentPage } from "@scandic-hotels/trpc/types/contentPage"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { findMyBookingCurrentWebPath } from "@scandic-hotels/common/constants/routes/findMyBooking"
|
||||
import { findMyBookingCurrentWebPath } from "@scandic-hotels/common/constants/routes/findMyBookingRoutes"
|
||||
import { logout } from "@scandic-hotels/common/constants/routes/handleAuth"
|
||||
import { myPages } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
|
||||
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
@@ -18,7 +19,6 @@ import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import Avatar from "@/components/MyPages/Avatar"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { useLazyPathname } from "@/hooks/useLazyPathname"
|
||||
import { trackClick, trackLoginClick } from "@/utils/tracking"
|
||||
|
||||
import BookingButton from "../BookingButton"
|
||||
@@ -152,7 +152,7 @@ export function MainMenu({
|
||||
<li className={styles.mobileLinkRow}>
|
||||
<LoginButton
|
||||
lang={lang}
|
||||
pathName={loginPathname}
|
||||
redirectTo={loginPathname}
|
||||
trackingId="loginStartHamburgerMenu"
|
||||
className={styles.mobileLinkButton}
|
||||
onClick={() => {
|
||||
|
||||
@@ -75,7 +75,7 @@ export default async function TopMenu({
|
||||
) : (
|
||||
<LoginButton
|
||||
lang={lang}
|
||||
pathName={pathname}
|
||||
redirectTo={pathname}
|
||||
trackingId="loginStartTopMenu"
|
||||
className={`${styles.sessionLink} ${styles.loginLink}`}
|
||||
size="small"
|
||||
@@ -112,7 +112,7 @@ export async function TopMenuSkeleton() {
|
||||
<li className={styles.sessionContainer}>
|
||||
<LoginButton
|
||||
lang={lang}
|
||||
pathName={pathname}
|
||||
redirectTo={pathname}
|
||||
trackingId="loginStartTopMenu"
|
||||
className={`${styles.sessionLink} ${styles.loginLink}`}
|
||||
size="small"
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SiteSectionObject,
|
||||
TrackingData,
|
||||
TrackingProps,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
|
||||
function createPageObject(trackingData: TrackingData) {
|
||||
const englishSegments = trackingData.englishUrl
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { logout } from "@scandic-hotels/common/constants/routes/handleAuth"
|
||||
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { usePhoneNumberParsing } from "@scandic-hotels/common/hooks/usePhoneNumberParsing"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
@@ -21,7 +22,6 @@ import { langToApiLang } from "@scandic-hotels/trpc/constants/apiLang"
|
||||
import { editProfile } from "@/actions/editProfile"
|
||||
import Dialog from "@/components/Dialog"
|
||||
import ChangeNameDisclaimer from "@/components/MyPages/Profile/ChangeNameDisclaimer"
|
||||
import usePhoneNumberParsing from "@/hooks/usePhoneNumberParsing"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
import { type EditProfileSchema, editProfileSchema } from "./schema"
|
||||
|
||||
@@ -6,8 +6,9 @@ import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { membershipTermsAndConditions } from "@scandic-hotels/common/constants/routes/membershipTermsAndConditions"
|
||||
import { privacyPolicyRoutes } from "@scandic-hotels/common/constants/routes/privacyPolicyRoutes"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { useFormTracking } from "@scandic-hotels/common/tracking/useFormTracking"
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
getDefaultCountryFromLang,
|
||||
@@ -20,6 +21,7 @@ import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { useFormTracking } from "@scandic-hotels/tracking/useFormTracking"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import {
|
||||
signupErrors,
|
||||
@@ -27,11 +29,6 @@ import {
|
||||
signUpSchema,
|
||||
} from "@scandic-hotels/trpc/routers/user/schemas"
|
||||
|
||||
import {
|
||||
membershipTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/webHrefs"
|
||||
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -290,7 +287,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
textDecoration="underline"
|
||||
color="Text/Interactive/Secondary"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
href={privacyPolicyRoutes[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Dialog, Modal } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { findMyBooking } from "@scandic-hotels/common/constants/routes/findMyBooking"
|
||||
import { customerService } from "@scandic-hotels/common/constants/routes/customerService"
|
||||
import { findMyBookingRoutes } from "@scandic-hotels/common/constants/routes/findMyBookingRoutes"
|
||||
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
|
||||
|
||||
import { customerService } from "@/constants/webHrefs"
|
||||
import useDropdownStore from "@/stores/main-menu"
|
||||
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher"
|
||||
@@ -71,7 +71,7 @@ export default function MobileMenu({
|
||||
defaultMessage: "Open menu",
|
||||
})
|
||||
|
||||
const findMyBookingUrl = findMyBooking[lang]
|
||||
const findMyBookingUrl = findMyBookingRoutes[lang]
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
|
||||
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
|
||||
import { Avatar } from "@scandic-hotels/design-system/Avatar"
|
||||
import { LoginButton } from "@scandic-hotels/design-system/LoginButton"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { useLazyPathname } from "@/hooks/useLazyPathname"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
import { trackLoginClick } from "@/utils/tracking"
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MyPagesMenuWrapper() {
|
||||
onClick={() => {
|
||||
trackLoginClick("top menu")
|
||||
}}
|
||||
pathName={loginPathname}
|
||||
redirectTo={loginPathname}
|
||||
trackingId="loginStartNewTopMenu"
|
||||
>
|
||||
<Avatar />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { findMyBooking } from "@scandic-hotels/common/constants/routes/findMyBooking"
|
||||
import { findMyBookingRoutes } from "@scandic-hotels/common/constants/routes/findMyBookingRoutes"
|
||||
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function TopMenu() {
|
||||
}
|
||||
|
||||
const lang = await getLang()
|
||||
const findMyBookingUrl = findMyBooking[lang]
|
||||
const findMyBookingUrl = findMyBookingRoutes[lang]
|
||||
|
||||
return (
|
||||
<div className={styles.topMenu}>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { clearPaymentInfoSessionStorage } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/helpers"
|
||||
import { useSearchHistory } from "@scandic-hotels/booking-flow/hooks/useSearchHistory"
|
||||
import { useBookingConfirmationStore } from "@scandic-hotels/booking-flow/stores/booking-confirmation"
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { clearPaymentInfoSessionStorage } from "../../EnterDetails/Payment/helpers"
|
||||
import { getTracking } from "./tracking"
|
||||
|
||||
import type { Room } from "@scandic-hotels/booking-flow/types/stores/booking-confirmation"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHash } from "crypto"
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { readPaymentInfoFromSessionStorage } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/helpers"
|
||||
import { invertedBedTypeMap } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||
import { getSpecialRoomType } from "@scandic-hotels/booking-flow/utils/specialRoomType"
|
||||
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
@@ -11,13 +13,10 @@ import {
|
||||
type TrackingSDKHotelInfo,
|
||||
type TrackingSDKPageData,
|
||||
type TrackingSDKPaymentInfo,
|
||||
} from "@scandic-hotels/common/tracking/types"
|
||||
} from "@scandic-hotels/tracking/types"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { readPaymentInfoFromSessionStorage } from "@/components/HotelReservation/EnterDetails/Payment/helpers"
|
||||
import { getSpecialRoomType } from "@/utils/specialRoomType"
|
||||
|
||||
import type { Room } from "@scandic-hotels/booking-flow/types/stores/booking-confirmation"
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { BED_TYPE_ICONS } from "@scandic-hotels/booking-flow/bedTypeIcons"
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import {
|
||||
BedTypeEnum,
|
||||
type ExtraBedTypeEnum,
|
||||
} from "@scandic-hotels/trpc/enums/bedType"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import { trackBedSelection } from "@/utils/tracking"
|
||||
|
||||
import { bedTypeFormSchema } from "./schema"
|
||||
|
||||
import styles from "./bedOptions.module.css"
|
||||
|
||||
import type { IconProps } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import type { BedTypeFormSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType() {
|
||||
const availableBeds = useEnterDetailsStore((state) => state.availableBeds)
|
||||
const {
|
||||
actions: { updateBedType },
|
||||
room: { bedType, bedTypes },
|
||||
} = useRoomContext()
|
||||
const initialBedType = bedType?.roomTypeCode
|
||||
const [previousBedType, setPreviousBedType] = useState("")
|
||||
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: initialBedType ? { bedType: initialBedType } : undefined,
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||
const matchingRoom = bedTypes?.find(
|
||||
(roomType) => roomType.value === bedTypeRoomCode.bedType
|
||||
)
|
||||
if (matchingRoom) {
|
||||
const bedType = {
|
||||
description: matchingRoom.description,
|
||||
roomTypeCode: matchingRoom.value,
|
||||
type: matchingRoom.type,
|
||||
}
|
||||
updateBedType(bedType)
|
||||
trackBedSelection(bedType.description)
|
||||
}
|
||||
},
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
const selectedBedType = methods.watch("bedType")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
if (selectedBedType && selectedBedType !== previousBedType) {
|
||||
handleSubmit(onSubmit)()
|
||||
setPreviousBedType(selectedBedType)
|
||||
}
|
||||
}, [
|
||||
selectedBedType,
|
||||
previousBedType,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
setPreviousBedType,
|
||||
])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{bedTypes.map((roomType) => {
|
||||
const width =
|
||||
roomType.size.max === roomType.size.min
|
||||
? `${roomType.size.min} cm`
|
||||
: `${roomType.size.min} cm - ${roomType.size.max} cm`
|
||||
|
||||
const bedAvailable = availableBeds[roomType.value]
|
||||
// This is needed since otherwise, picking the last room would make
|
||||
// the card disabled
|
||||
const isSameBedAsSelected = bedType?.roomTypeCode === roomType.value
|
||||
return (
|
||||
<RadioCard
|
||||
key={roomType.value}
|
||||
Icon={(props) => (
|
||||
<BedIconRenderer
|
||||
mainBedType={roomType.type}
|
||||
extraBedType={roomType.extraBed?.type}
|
||||
props={props}
|
||||
/>
|
||||
)}
|
||||
disabled={!bedAvailable && !isSameBedAsSelected}
|
||||
id={roomType.value}
|
||||
name="bedType"
|
||||
subtitle={
|
||||
roomType.type !== BedTypeEnum.CustomOccupancy ? width : null
|
||||
}
|
||||
title={roomType.description}
|
||||
value={roomType.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function BedIconRenderer({
|
||||
mainBedType,
|
||||
extraBedType,
|
||||
props,
|
||||
}: {
|
||||
mainBedType: BedTypeEnum
|
||||
extraBedType: ExtraBedTypeEnum | undefined
|
||||
props: IconProps
|
||||
}) {
|
||||
const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other
|
||||
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
|
||||
|
||||
if (!MainBedIcon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${props.className} ${styles.iconContainer}`}>
|
||||
<MainBedIcon height={32} />
|
||||
{ExtraBedIcon && <ExtraBedIcon height={32} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const bedTypeFormSchema = z.object({
|
||||
bedType: z.string(),
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
width: min(696px, 100%);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import RadioCard from "@scandic-hotels/design-system/Form/RadioCard"
|
||||
import BreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/BreakfastBuffetIcon"
|
||||
import NoBreakfastBuffetIcon from "@scandic-hotels/design-system/Icons/NoBreakfastBuffetIcon"
|
||||
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import { trackBreakfastSelection } from "@/utils/tracking"
|
||||
|
||||
import { breakfastFormSchema } from "./schema"
|
||||
|
||||
import styles from "./breakfast.module.css"
|
||||
|
||||
import type { BreakfastFormSchema } from "@/types/components/hotelReservation/breakfast"
|
||||
|
||||
export default function Breakfast() {
|
||||
const intl = useIntl()
|
||||
const packages = useEnterDetailsStore((state) => state.breakfastPackages)
|
||||
const hotelId = useEnterDetailsStore((state) => state.booking.hotelId)
|
||||
const {
|
||||
actions: { updateBreakfast },
|
||||
room,
|
||||
} = useRoomContext()
|
||||
|
||||
const hasChildrenInRoom = !!room.childrenInRoom?.length
|
||||
const totalPriceForNoBreakfast = 0
|
||||
|
||||
const breakfastSelection = room?.breakfast
|
||||
? room.breakfast.code
|
||||
: room?.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: breakfastSelection ? { breakfast: breakfastSelection } : undefined,
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BreakfastFormSchema) => {
|
||||
const pkg = packages.find((p) => p.code === values.breakfast)
|
||||
if (pkg) {
|
||||
updateBreakfast(pkg)
|
||||
} else {
|
||||
updateBreakfast(false)
|
||||
}
|
||||
trackBreakfastSelection({
|
||||
breakfastPackage: pkg ?? packages[0],
|
||||
hotelId,
|
||||
units: pkg ? room.adults : 0,
|
||||
})
|
||||
},
|
||||
[packages, hotelId, room.adults, updateBreakfast]
|
||||
)
|
||||
|
||||
const selectedBreakfast = methods.watch("breakfast")
|
||||
const handleSubmit = methods.handleSubmit
|
||||
useEffect(() => {
|
||||
if (selectedBreakfast) {
|
||||
handleSubmit(onSubmit)()
|
||||
}
|
||||
}, [selectedBreakfast, handleSubmit, onSubmit])
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.container}>
|
||||
{hasChildrenInRoom ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Children's breakfast is always free as part of the adult's breakfast.",
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{packages?.map((pkg) => (
|
||||
<RadioCard
|
||||
key={pkg.code}
|
||||
name="breakfast"
|
||||
value={pkg.code}
|
||||
Icon={BreakfastBuffetIcon}
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Breakfast buffet",
|
||||
})}
|
||||
subtitle={
|
||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Included",
|
||||
})
|
||||
: `+ ${formatPrice(intl, pkg.localPrice.price, pkg.localPrice.currency ?? "")}`
|
||||
}
|
||||
subtitleSecondary={intl.formatMessage({
|
||||
defaultMessage: "Per adult/night",
|
||||
})}
|
||||
description={
|
||||
hasChildrenInRoom
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Free for kids aged 12 and under.",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
descriptionSecondary={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Includes vegan, gluten-free, and other allergy-friendly options.",
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<RadioCard
|
||||
name="breakfast"
|
||||
value="false"
|
||||
Icon={NoBreakfastBuffetIcon}
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "No breakfast",
|
||||
})}
|
||||
subtitle={`+ ${formatPrice(intl, totalPriceForNoBreakfast, packages?.[0].localPrice.currency ?? "")}`}
|
||||
descriptionSecondary={
|
||||
hasChildrenInRoom
|
||||
? intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Breakfast can be added after booking for an extra cost for adults and kids ages 4 and up.",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Breakfast can be added after booking for an additional fee.",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const breakfastFormSchema = z.object({
|
||||
breakfast: z.string().or(z.literal("false")),
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.guarantee {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.infoButton {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
justify-self: flex-end;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background-color: var(--Overlay-40);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
z-index: var(--default-modal-overlay-z-index);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
|
||||
overflow: hidden;
|
||||
padding: var(--Spacing-x5) var(--Spacing-x3);
|
||||
width: min(90dvw, 560px);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
margin-top: var(--Space-x15);
|
||||
outline: none;
|
||||
width: min(164px, 100%);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.btnText {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client"
|
||||
import { useLayoutEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import useSetOverflowVisibleOnRA from "@scandic-hotels/common/hooks/useSetOverflowVisibleOnRA"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { PaymentOption } from "@scandic-hotels/design-system/Form/PaymentOption"
|
||||
import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup"
|
||||
import { SelectPaymentMethod } from "@scandic-hotels/design-system/Form/SelectPaymentMethod"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { trackUpdatePaymentMethod } from "@/utils/tracking"
|
||||
|
||||
import styles from "./guarantee.module.css"
|
||||
|
||||
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
|
||||
|
||||
interface GuaranteeProps {
|
||||
savedCreditCards: CreditCard[] | null
|
||||
}
|
||||
|
||||
export default function Guarantee({ savedCreditCards }: GuaranteeProps) {
|
||||
const intl = useIntl()
|
||||
const guarantee = useWatch({ name: "guarantee" })
|
||||
return (
|
||||
<div className={styles.guarantee}>
|
||||
<Checkbox name="guarantee">
|
||||
<div className={styles.checkboxContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"I may arrive later than 18:00 and want to guarantee my booking.",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
className={styles.infoButton}
|
||||
size="Small"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
variant="Text"
|
||||
>
|
||||
<MaterialIcon icon="info" size={20} color="CurrentColor" />
|
||||
<span className={styles.btnText}>
|
||||
{intl.formatMessage({ defaultMessage: "How it works" })}
|
||||
</span>
|
||||
</Button>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<div className={styles.container}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Guarantee for late arrival",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p className={styles.text}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"When guaranteeing your booking with a credit card, we will hold the booking until 07:00 the day after check-in.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.text}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"In case of a no-show, your credit card will be charged for the first night.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
onPress={close}
|
||||
size="Small"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
variant="Secondary"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
</Button>
|
||||
<RestoreOverflow />
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</Checkbox>
|
||||
{savedCreditCards?.length && guarantee ? (
|
||||
<SelectPaymentMethod
|
||||
formName="paymentMethod"
|
||||
onChange={(method) => trackUpdatePaymentMethod({ method })}
|
||||
paymentMethods={savedCreditCards.map((x) => ({
|
||||
...x,
|
||||
cardType: x.cardType as PaymentMethodEnum,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
{guarantee ? (
|
||||
<PaymentOptionsGroup
|
||||
name="paymentMethod"
|
||||
onChange={(method) => trackUpdatePaymentMethod({ method })}
|
||||
label={
|
||||
savedCreditCards?.length
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "OTHER",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PaymentOption
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Credit card",
|
||||
})}
|
||||
/>
|
||||
</PaymentOptionsGroup>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RestoreOverflow() {
|
||||
const setOverflowVisible = useSetOverflowVisibleOnRA()
|
||||
useLayoutEffect(() => {
|
||||
setOverflowVisible(true)
|
||||
}, [setOverflowVisible])
|
||||
return null
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.selections {
|
||||
background-color: var(--Surface-Secondary-Default);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
width: min(800px, 100%);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import TermsAndConditions from "../Payment/TermsAndConditions"
|
||||
import Guarantee from "./Guarantee"
|
||||
|
||||
import styles from "./confirm.module.css"
|
||||
|
||||
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
|
||||
|
||||
interface ConfirmBookingProps {
|
||||
savedCreditCards: CreditCard[] | null
|
||||
}
|
||||
|
||||
export default function ConfirmBooking({
|
||||
savedCreditCards,
|
||||
}: ConfirmBookingProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.selections}>
|
||||
<Guarantee savedCreditCards={savedCreditCards} />
|
||||
<Divider color="Border/Divider/Default" />
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<TermsAndConditions isFlexBookingTerms />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmBookingRedemption() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.selections}>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.guaranteeContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"When you confirm the booking the room will be guaranteed for late arrival. If you fail to arrive without cancelling in advance or if you cancel after 18:00 local time, you will be charged for one reward night.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<TermsAndConditions isFlexBookingTerms />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import MagicWandIcon from "@scandic-hotels/design-system/Icons/MagicWandIcon"
|
||||
import Modal from "@scandic-hotels/design-system/Modal"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
|
||||
import styles from "./modal.module.css"
|
||||
|
||||
export default function MemberPriceModal() {
|
||||
const {
|
||||
actions: { updatePriceForMembershipNo },
|
||||
room,
|
||||
} = useRoomContext()
|
||||
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { getFieldState, trigger } = useFormContext()
|
||||
|
||||
const [join, membershipNo] = useWatch({ name: ["join", "membershipNo"] })
|
||||
|
||||
useEffect(() => {
|
||||
if (join) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}, [join])
|
||||
|
||||
useEffect(() => {
|
||||
trigger("membershipNo").then((isValid) => {
|
||||
const { isDirty } = getFieldState("membershipNo")
|
||||
updatePriceForMembershipNo(membershipNo, isValid)
|
||||
if (isValid && isDirty) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
})
|
||||
}, [getFieldState, membershipNo, trigger, updatePriceForMembershipNo])
|
||||
|
||||
if (!memberRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onToggle={() => setIsOpen(false)}>
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.innerModalContent}>
|
||||
<MagicWandIcon width="265px" />
|
||||
<Title as="h3" level="h1" textTransform="regular">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Member room price activated",
|
||||
})}
|
||||
</Title>
|
||||
|
||||
{memberPrice && (
|
||||
<span className={styles.newPrice}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "The new price is",
|
||||
})}
|
||||
</Body>
|
||||
<Subtitle type="two" color="red">
|
||||
{formatPrice(
|
||||
intl,
|
||||
memberPrice.pricePerStay ?? 0,
|
||||
memberPrice.currency ?? CurrencyEnum.Unknown
|
||||
)}
|
||||
</Subtitle>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button intent="primary" theme="base" onClick={() => setIsOpen(false)}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "OK",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.modalContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.innerModalContent {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.newPrice {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modalContent {
|
||||
width: 352px;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { membershipTermsAndConditions } from "@/constants/webHrefs"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name = "join",
|
||||
}: JoinScandicFriendsCardProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const {
|
||||
room,
|
||||
roomNr,
|
||||
actions: { updateJoin },
|
||||
} = useRoomContext()
|
||||
|
||||
function onChange(event: { target: { value: boolean } }) {
|
||||
updateJoin(event.target.value)
|
||||
}
|
||||
|
||||
if (!("member" in room.roomRate) || !room.roomRate.member) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cardContainer}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2 className={styles.priceContainer}>
|
||||
<span>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{`${intl.formatMessage({
|
||||
defaultMessage: "Get the member room price",
|
||||
})}: `}
|
||||
</span>
|
||||
<span className={styles.price}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{amount} for room {roomNr}",
|
||||
},
|
||||
{
|
||||
amount: formatPrice(
|
||||
intl,
|
||||
room.roomRate.member.localPrice.pricePerStay ?? 0,
|
||||
room.roomRate.member.localPrice.currency ??
|
||||
CurrencyEnum.Unknown
|
||||
),
|
||||
roomNr,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</h2>
|
||||
</Typography>
|
||||
<Checkbox
|
||||
name={name}
|
||||
className={styles.checkBox}
|
||||
registerOptions={{ onChange, value: room.guest.join }}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Join Scandic Friends before check-in",
|
||||
})}
|
||||
</div>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
|
||||
<div className={styles.terms}>
|
||||
<Footnote color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"By joining you accept the <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. The Scandic Friends Membership is valid until further notice, but can at any time be terminated by contacting Scandic Customer Service.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
href={membershipTermsAndConditions[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.cardContainer {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Surface-Primary-Hover-Accent);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x2);
|
||||
grid-template-areas:
|
||||
"price"
|
||||
"checkbox"
|
||||
"terms";
|
||||
width: min(100%, 696px);
|
||||
}
|
||||
|
||||
.priceContainer {
|
||||
grid-area: price;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
|
||||
.checkBox {
|
||||
align-self: center;
|
||||
grid-area: checkbox;
|
||||
}
|
||||
|
||||
.terms {
|
||||
grid-area: terms;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.cardContainer {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: var(--Space-x3);
|
||||
grid-template-areas:
|
||||
"price checkbox"
|
||||
"terms terms";
|
||||
}
|
||||
|
||||
.priceContainer {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: min(100%, 696px);
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useFormTracking } from "@scandic-hotels/common/tracking/useFormTracking"
|
||||
import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import usePhoneNumberParsing from "@/hooks/usePhoneNumberParsing"
|
||||
import { getFormattedCountryList } from "@/utils/countries"
|
||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
|
||||
import MemberPriceModal from "../MemberPriceModal"
|
||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||
import { getMultiroomDetailsSchema } from "./schema"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const { addPreSubmitCallback, rooms } = useEnterDetailsStore((state) => ({
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
rooms: state.rooms,
|
||||
}))
|
||||
|
||||
const {
|
||||
actions: { updateDetails, updatePartialGuestData, setIncomplete },
|
||||
idx,
|
||||
room,
|
||||
roomNr,
|
||||
} = useRoomContext()
|
||||
const initialData = room.guest
|
||||
|
||||
/**
|
||||
* The data that each room needs from each other to do validations
|
||||
* across the rooms
|
||||
*/
|
||||
const crossValidationData = useMemo(
|
||||
() =>
|
||||
rooms
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((room) => ({
|
||||
firstName: room.room.guest.firstName,
|
||||
lastName: room.room.guest.lastName,
|
||||
membershipNo: room.room.guest.membershipNo,
|
||||
})),
|
||||
[idx, rooms]
|
||||
)
|
||||
|
||||
const { phoneNumber, phoneNumberCC } = usePhoneNumberParsing(
|
||||
initialData.phoneNumber,
|
||||
initialData.phoneNumberCC
|
||||
)
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
countryCode: initialData.countryCode,
|
||||
email: initialData.email,
|
||||
firstName: initialData.firstName,
|
||||
join: initialData.join,
|
||||
lastName: initialData.lastName,
|
||||
membershipNo: initialData.membershipNo,
|
||||
phoneNumber,
|
||||
phoneNumberCC,
|
||||
specialRequest: {
|
||||
comment: room.specialRequest.comment,
|
||||
},
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "onBlur",
|
||||
resolver: zodResolver(getMultiroomDetailsSchema(crossValidationData)),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
trigger,
|
||||
control,
|
||||
subscribe,
|
||||
formState: { isValid, errors },
|
||||
setValue,
|
||||
watch,
|
||||
getValues,
|
||||
} = methods
|
||||
|
||||
const { trackFormSubmit } = useFormTracking(
|
||||
"checkout",
|
||||
subscribe,
|
||||
control,
|
||||
` - room ${roomNr}`
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
trackFormSubmit()
|
||||
}
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
|
||||
const updateDetailsStore = useCallback(() => {
|
||||
if (isValid) {
|
||||
handleSubmit(updateDetails)()
|
||||
} else {
|
||||
updatePartialGuestData({
|
||||
firstName: getValues("firstName")?.toString(),
|
||||
lastName: getValues("lastName")?.toString(),
|
||||
membershipNo: getValues("membershipNo")?.toString(),
|
||||
})
|
||||
setIncomplete()
|
||||
}
|
||||
}, [
|
||||
handleSubmit,
|
||||
isValid,
|
||||
setIncomplete,
|
||||
updateDetails,
|
||||
updatePartialGuestData,
|
||||
getValues,
|
||||
])
|
||||
|
||||
useEffect(updateDetailsStore, [updateDetailsStore])
|
||||
|
||||
// Trigger validation of the room manually when another room changes its data.
|
||||
// Only do it if the field has a value, to avoid error states before the user
|
||||
// has filled anything in.
|
||||
useEffect(() => {
|
||||
const { firstName, lastName, membershipNo } = methods.getValues()
|
||||
if (firstName) {
|
||||
methods.trigger("firstName")
|
||||
}
|
||||
if (lastName) {
|
||||
methods.trigger("lastName")
|
||||
}
|
||||
if (membershipNo) {
|
||||
methods.trigger("membershipNo")
|
||||
}
|
||||
}, [crossValidationData, methods])
|
||||
|
||||
const countryCode = watch("countryCode")
|
||||
|
||||
useEffect(() => {
|
||||
if (countryCode) {
|
||||
setValue("phoneNumberCC", countryCode.toLowerCase())
|
||||
}
|
||||
}, [countryCode, setValue])
|
||||
|
||||
const guestIsGoingToJoin = methods.watch("join")
|
||||
const guestIsMember = methods.watch("membershipNo")
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={`${formID}-room-${roomNr}`}
|
||||
onSubmit={methods.handleSubmit(updateDetails)}
|
||||
>
|
||||
{guestIsMember ? null : <JoinScandicFriendsCard />}
|
||||
<div className={styles.container}>
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<Input
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
onBlur: updateDetailsStore,
|
||||
}}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(intl, errors.countryCode?.message)}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Input
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
countryLabel={intl.formatMessage({
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(intl, errors.phoneNumber?.message)}
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
{guestIsGoingToJoin ? null : (
|
||||
<Input
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Membership ID",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
/>
|
||||
)}
|
||||
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
|
||||
</div>
|
||||
<MemberPriceModal />
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
|
||||
|
||||
import { specialRequestSchema } from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests/schema"
|
||||
|
||||
// stringMatcher regex is copied from current web as specified by requirements.
|
||||
const stringMatcher =
|
||||
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
|
||||
|
||||
const isValidString = (key: string) => stringMatcher.test(key)
|
||||
|
||||
export const multiroomErrors = {
|
||||
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
|
||||
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
|
||||
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
|
||||
FIRST_AND_LAST_NAME_UNIQUE: "FIRST_AND_LAST_NAME_UNIQUE",
|
||||
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
|
||||
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
|
||||
PHONE_REQUIRED: "PHONE_REQUIRED",
|
||||
PHONE_REQUESTED: "PHONE_REQUESTED",
|
||||
EMAIL_REQUIRED: "EMAIL_REQUIRED",
|
||||
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
|
||||
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
|
||||
MEMBERSHIP_NO_UNIQUE: "MEMBERSHIP_NO_UNIQUE",
|
||||
} as const
|
||||
|
||||
export type CrossValidationData = {
|
||||
firstName: string | undefined
|
||||
lastName: string | undefined
|
||||
membershipNo: string | undefined
|
||||
}
|
||||
|
||||
export function getMultiroomDetailsSchema(
|
||||
crossValidationData: CrossValidationData[] | undefined = []
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
|
||||
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
|
||||
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
||||
join: z.boolean().default(false),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, multiroomErrors.LAST_NAME_REQUIRED)
|
||||
.refine(isValidString, multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS),
|
||||
phoneNumber: phoneValidator(
|
||||
multiroomErrors.PHONE_REQUIRED,
|
||||
multiroomErrors.PHONE_REQUESTED
|
||||
),
|
||||
phoneNumberCC: z.string(),
|
||||
membershipNo: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
if (val) {
|
||||
return !val.match(/[^0-9]/g)
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
|
||||
specialRequest: specialRequestSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) =>
|
||||
room.firstName?.toLowerCase() === data.firstName.toLowerCase() &&
|
||||
room.lastName?.toLowerCase() === data.lastName.toLowerCase()
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
|
||||
path: ["firstName"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) =>
|
||||
room.firstName?.toLowerCase() === data.firstName.toLowerCase() &&
|
||||
room.lastName?.toLowerCase() === data.lastName.toLowerCase()
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
|
||||
path: ["lastName"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) => room.membershipNo && room.membershipNo === data.membershipNo
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.MEMBERSHIP_NO_UNIQUE,
|
||||
path: ["membershipNo"],
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { LoginButton } from "@scandic-hotels/design-system/LoginButton"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { membershipTermsAndConditions } from "@/constants/webHrefs"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { useLazyPathname } from "@/hooks/useLazyPathname"
|
||||
import { trackLoginClick } from "@/utils/tracking"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name = "join",
|
||||
}: JoinScandicFriendsCardProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const loginPathname = useLazyPathname({ includeSearchParams: true })
|
||||
const {
|
||||
room,
|
||||
actions: { updateJoin },
|
||||
} = useRoomContext()
|
||||
|
||||
function onChange(event: { target: { value: boolean } }) {
|
||||
updateJoin(event.target.value)
|
||||
}
|
||||
|
||||
if (!("member" in room.roomRate) || !room.roomRate.member) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cardContainer}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h2 className={styles.priceContainer}>
|
||||
<span>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{`${intl.formatMessage({
|
||||
defaultMessage: "Get the member room price",
|
||||
})}: `}
|
||||
</span>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomRate.member.localPrice.pricePerStay ?? 0,
|
||||
room.roomRate.member.localPrice.currency ?? CurrencyEnum.Unknown
|
||||
)}
|
||||
</span>
|
||||
</h2>
|
||||
</Typography>
|
||||
<Checkbox
|
||||
name={name}
|
||||
className={styles.checkBox}
|
||||
registerOptions={{ onChange }}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Join Scandic Friends now",
|
||||
})}
|
||||
</div>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
|
||||
<Button size="small" color="Primary" asChild>
|
||||
<LoginButton
|
||||
className={styles.login}
|
||||
color="white"
|
||||
lang={lang}
|
||||
onClick={() => {
|
||||
trackLoginClick("enter details")
|
||||
}}
|
||||
pathName={loginPathname}
|
||||
trackingId="join-scandic-friends-enter-details"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Log in",
|
||||
})}
|
||||
</LoginButton>
|
||||
</Button>
|
||||
|
||||
<div className={styles.terms}>
|
||||
<Footnote color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"By joining you accept the <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. The Scandic Friends Membership is valid until further notice, but can at any time be terminated by contacting Scandic Customer Service.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
href={membershipTermsAndConditions[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.cardContainer {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Surface-Primary-Hover-Accent);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x2);
|
||||
grid-template-areas:
|
||||
"price login"
|
||||
"checkbox checkbox"
|
||||
"terms terms";
|
||||
width: min(100%, 696px);
|
||||
}
|
||||
|
||||
.priceContainer {
|
||||
grid-area: price;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
|
||||
.login {
|
||||
grid-area: login;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.checkBox {
|
||||
align-self: center;
|
||||
grid-area: checkbox;
|
||||
}
|
||||
|
||||
.terms {
|
||||
grid-area: terms;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.cardContainer {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: var(--Space-x3);
|
||||
grid-template-areas:
|
||||
"price checkbox login"
|
||||
"terms terms terms";
|
||||
}
|
||||
|
||||
.priceContainer {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
type FieldErrors,
|
||||
type RegisterOptions,
|
||||
useWatch,
|
||||
} from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
||||
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
|
||||
import styles from "./signup.module.css"
|
||||
|
||||
export default function Signup({
|
||||
errors,
|
||||
name,
|
||||
registerOptions,
|
||||
}: {
|
||||
errors: FieldErrors
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const [isJoinChecked, setIsJoinChecked] = useState(false)
|
||||
|
||||
const joinValue = useWatch({ name })
|
||||
|
||||
useEffect(() => {
|
||||
// In order to avoid hydration errors the state needs to be set as side effect,
|
||||
// since the join value can come from search params
|
||||
setIsJoinChecked(joinValue)
|
||||
}, [joinValue])
|
||||
|
||||
return isJoinChecked ? (
|
||||
<div className={styles.additionalFormData}>
|
||||
<Input
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Zip code",
|
||||
})}
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
<span className={styles.required}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Birth date",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect
|
||||
labels={{
|
||||
day: intl.formatMessage({ defaultMessage: "Day" }),
|
||||
month: intl.formatMessage({ defaultMessage: "Month" }),
|
||||
year: intl.formatMessage({ defaultMessage: "Year" }),
|
||||
errorMessage: getErrorMessage(
|
||||
intl,
|
||||
errors["dateOfBirth"]?.message?.toString()
|
||||
),
|
||||
}}
|
||||
lang={lang}
|
||||
name="dateOfBirth"
|
||||
registerOptions={{ required: true, ...registerOptions }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Membership ID",
|
||||
})}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
registerOptions={registerOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-column: 1/-1;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.additionalFormData {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.dateField {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.required:after {
|
||||
content: " *";
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
width: min(100%, 696px);
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useFormTracking } from "@scandic-hotels/common/tracking/useFormTracking"
|
||||
import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import SpecialRequests from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import usePhoneNumberParsing from "@/hooks/usePhoneNumberParsing"
|
||||
import { getFormattedCountryList } from "@/utils/countries"
|
||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
|
||||
import MemberPriceModal from "../MemberPriceModal"
|
||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type {
|
||||
DetailsProps,
|
||||
DetailsSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const { lastRoom, addPreSubmitCallback } = useEnterDetailsStore((state) => ({
|
||||
lastRoom: state.lastRoom,
|
||||
addPreSubmitCallback: state.actions.addPreSubmitCallback,
|
||||
}))
|
||||
|
||||
const {
|
||||
actions: { updateDetails, updatePartialGuestData, setIncomplete },
|
||||
room,
|
||||
roomNr,
|
||||
idx,
|
||||
} = useRoomContext()
|
||||
|
||||
const initialData = room.guest
|
||||
|
||||
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
|
||||
|
||||
const { phoneNumber, phoneNumberCC } = usePhoneNumberParsing(
|
||||
user?.phoneNumber || initialData.phoneNumber,
|
||||
initialData.phoneNumberCC
|
||||
)
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
countryCode: user?.address?.countryCode || initialData.countryCode,
|
||||
dateOfBirth:
|
||||
"dateOfBirth" in initialData ? initialData.dateOfBirth : undefined,
|
||||
email: user?.email || initialData.email,
|
||||
firstName: user?.firstName || initialData.firstName,
|
||||
join: initialData.join,
|
||||
lastName: user?.lastName || initialData.lastName,
|
||||
membershipNo: initialData.membershipNo,
|
||||
phoneNumber,
|
||||
phoneNumberCC,
|
||||
zipCode: "zipCode" in initialData ? initialData.zipCode : undefined,
|
||||
specialRequest: {
|
||||
comment: room.specialRequest.comment,
|
||||
},
|
||||
},
|
||||
criteriaMode: "all",
|
||||
mode: "onBlur",
|
||||
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const {
|
||||
formState,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
control,
|
||||
subscribe,
|
||||
setValue,
|
||||
watch,
|
||||
getValues,
|
||||
} = methods
|
||||
|
||||
const { trackFormSubmit } = useFormTracking(
|
||||
"checkout",
|
||||
subscribe,
|
||||
control,
|
||||
lastRoom === idx ? "" : " - room 1"
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function callback() {
|
||||
trigger()
|
||||
trackFormSubmit()
|
||||
}
|
||||
addPreSubmitCallback(`${idx}-details`, callback)
|
||||
}, [addPreSubmitCallback, idx, trigger, trackFormSubmit])
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: DetailsSchema) => {
|
||||
updateDetails(values)
|
||||
},
|
||||
[updateDetails]
|
||||
)
|
||||
|
||||
const updateDetailsStore = useCallback(() => {
|
||||
if (formState.isValid) {
|
||||
handleSubmit(onSubmit)()
|
||||
} else {
|
||||
updatePartialGuestData({
|
||||
firstName: getValues("firstName")?.toString(),
|
||||
lastName: getValues("lastName")?.toString(),
|
||||
membershipNo: getValues("membershipNo")?.toString(),
|
||||
})
|
||||
setIncomplete()
|
||||
}
|
||||
}, [
|
||||
handleSubmit,
|
||||
formState.isValid,
|
||||
onSubmit,
|
||||
setIncomplete,
|
||||
updatePartialGuestData,
|
||||
getValues,
|
||||
])
|
||||
|
||||
useEffect(updateDetailsStore, [updateDetailsStore])
|
||||
const countryCode = watch("countryCode")
|
||||
|
||||
useEffect(() => {
|
||||
if (countryCode) {
|
||||
setValue("phoneNumberCC", countryCode.toLowerCase())
|
||||
}
|
||||
}, [countryCode, setValue])
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={`${formID}-room-${roomNr}`}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
{user || !memberRate ? null : <JoinScandicFriendsCard />}
|
||||
<div className={styles.container}>
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Guest information",
|
||||
})}
|
||||
</Footnote>
|
||||
<Input
|
||||
autoComplete="given-name"
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "First name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Input
|
||||
autoComplete="family-name"
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Last name",
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Country",
|
||||
})}
|
||||
lang={lang}
|
||||
countries={getFormattedCountryList(intl)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
formState.errors.countryCode?.message
|
||||
)}
|
||||
name="countryCode"
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
disabled={!!user}
|
||||
/>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
className={styles.fullWidth}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Email address",
|
||||
})}
|
||||
name="email"
|
||||
readOnly={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
<Phone
|
||||
className={styles.fullWidth}
|
||||
countryLabel={intl.formatMessage({
|
||||
defaultMessage: "Country code",
|
||||
})}
|
||||
countriesWithTranslatedName={getFormattedCountryList(intl)}
|
||||
defaultCountryCode={getDefaultCountryFromLang(lang)}
|
||||
errorMessage={getErrorMessage(
|
||||
intl,
|
||||
formState.errors.phoneNumber?.message
|
||||
)}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
name="phoneNumber"
|
||||
disabled={!!user}
|
||||
registerOptions={{ required: true, onBlur: updateDetailsStore }}
|
||||
/>
|
||||
{user ? null : (
|
||||
<div className={styles.fullWidth}>
|
||||
<Signup
|
||||
errors={formState.errors}
|
||||
name="join"
|
||||
registerOptions={{ onBlur: updateDetailsStore }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SpecialRequests registerOptions={{ onBlur: updateDetailsStore }} />
|
||||
</div>
|
||||
<MemberPriceModal />
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
|
||||
|
||||
import { specialRequestSchema } from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests/schema"
|
||||
|
||||
// stringMatcher regex is copied from current web as specified by requirements.
|
||||
const stringMatcher =
|
||||
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
|
||||
|
||||
const isValidString = (key: string) => stringMatcher.test(key)
|
||||
|
||||
export const roomOneErrors = {
|
||||
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
|
||||
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
|
||||
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
|
||||
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
|
||||
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
|
||||
PHONE_REQUIRED: "PHONE_REQUIRED",
|
||||
PHONE_REQUESTED: "PHONE_REQUESTED",
|
||||
EMAIL_REQUIRED: "EMAIL_REQUIRED",
|
||||
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
|
||||
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
|
||||
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
|
||||
ZIP_CODE_INVALID: "ZIP_CODE_INVALID",
|
||||
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
|
||||
BIRTH_DATE_AGE_18: "BIRTH_DATE_AGE_18",
|
||||
} as const
|
||||
|
||||
export const baseDetailsSchema = z.object({
|
||||
countryCode: z.string().min(1, roomOneErrors.COUNTRY_REQUIRED),
|
||||
email: z.string().email(roomOneErrors.EMAIL_REQUIRED),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, roomOneErrors.FIRST_NAME_REQUIRED)
|
||||
.refine(isValidString, roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, roomOneErrors.LAST_NAME_REQUIRED)
|
||||
.refine(isValidString, roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS),
|
||||
phoneNumber: phoneValidator(
|
||||
roomOneErrors.PHONE_REQUIRED,
|
||||
roomOneErrors.PHONE_REQUESTED
|
||||
),
|
||||
phoneNumberCC: z.string(),
|
||||
specialRequest: specialRequestSchema,
|
||||
})
|
||||
|
||||
export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal<boolean>(false),
|
||||
zipCode: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
membershipNo: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
if (val) {
|
||||
return !val.match(/[^0-9]/g)
|
||||
}
|
||||
return true
|
||||
}, roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS)
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
}
|
||||
return true
|
||||
}, roomOneErrors.MEMBERSHIP_NO_INVALID),
|
||||
})
|
||||
)
|
||||
|
||||
export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z
|
||||
.string()
|
||||
.min(1, roomOneErrors.ZIP_CODE_REQUIRED)
|
||||
.regex(/^[A-Za-z0-9-\s]{1,9}$/g, roomOneErrors.ZIP_CODE_INVALID),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.min(1, roomOneErrors.BIRTH_DATE_REQUIRED)
|
||||
.refine((date) => {
|
||||
const today = dt()
|
||||
const dob = dt(date)
|
||||
const age = today.diff(dob, "year")
|
||||
return age >= 18
|
||||
}, roomOneErrors.BIRTH_DATE_AGE_18),
|
||||
membershipNo: z.string().default(""),
|
||||
})
|
||||
)
|
||||
|
||||
export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
notJoinDetailsSchema,
|
||||
joinDetailsSchema,
|
||||
])
|
||||
|
||||
// For signed in users we accept partial or invalid data. Users cannot
|
||||
// change their info in this flow, so we don't want to validate it.
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
firstName: z.string().default(""),
|
||||
lastName: z.string().default(""),
|
||||
membershipNo: z.string().default(""),
|
||||
phoneNumber: z.string().default(""),
|
||||
phoneNumberCC: z.string().default(""),
|
||||
join: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.transform((_) => false),
|
||||
dateOfBirth: z.string().default(""),
|
||||
zipCode: z.string().default(""),
|
||||
specialRequest: specialRequestSchema,
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import TextArea from "@scandic-hotels/design-system/Form/TextArea"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./specialRequests.module.css"
|
||||
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export default function SpecialRequests({
|
||||
registerOptions,
|
||||
}: {
|
||||
registerOptions?: RegisterOptions
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.requests}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p className={styles.heading}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Special requests (optional)",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<div className={styles.content}>
|
||||
{/*
|
||||
|
||||
TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready.
|
||||
|
||||
<Select
|
||||
label={intl.formatMessage({ defaultMessage: "Floor preference" })}
|
||||
name="specialRequest.floorPreference"
|
||||
items={[
|
||||
noPreferenceItem,
|
||||
{
|
||||
value: FloorPreference.HIGH,
|
||||
label: intl.formatMessage({ defaultMessage: "High floor" }),
|
||||
},
|
||||
{
|
||||
value: FloorPreference.LOW,
|
||||
label: intl.formatMessage({ defaultMessage: "Low floor" }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={intl.formatMessage({ defaultMessage: "Elevator preference" })}
|
||||
name="specialRequest.elevatorPreference"
|
||||
items={[
|
||||
noPreferenceItem,
|
||||
{
|
||||
value: ElevatorPreference.AWAY_FROM_ELEVATOR,
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Away from elevator",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: ElevatorPreference.NEAR_ELEVATOR,
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Near elevator",
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/> */}
|
||||
<TextArea
|
||||
label={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Is there anything else you would like us to know before your arrival?",
|
||||
})}
|
||||
name="specialRequest.comment"
|
||||
registerOptions={registerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export enum FloorPreference {
|
||||
LOW = "Low floor",
|
||||
HIGH = "High floor",
|
||||
}
|
||||
|
||||
export enum ElevatorPreference {
|
||||
AWAY_FROM_ELEVATOR = "Away from elevator",
|
||||
NEAR_ELEVATOR = "Near elevator",
|
||||
}
|
||||
|
||||
export const specialRequestSchema = z
|
||||
.object({
|
||||
floorPreference: z
|
||||
.nativeEnum(FloorPreference)
|
||||
.or(z.literal("").transform((_) => undefined))
|
||||
.optional(),
|
||||
elevatorPreference: z
|
||||
.nativeEnum(ElevatorPreference)
|
||||
.or(z.literal("").transform((_) => undefined))
|
||||
.optional(),
|
||||
comment: z.string().default(""),
|
||||
})
|
||||
.optional()
|
||||
@@ -1,14 +0,0 @@
|
||||
.requests {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
.header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
60deg,
|
||||
rgb(0 0 0 / 25%) 0%,
|
||||
rgb(0 0 0 / 50%) 50%,
|
||||
rgb(0 0 0 / 75%) 100%
|
||||
);
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: var(--max-width-page);
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x3) 0 var(--Space-x4);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.mobileTitle {
|
||||
display: -webkit-box;
|
||||
}
|
||||
|
||||
.mobileTitle,
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.address {
|
||||
text-align: center;
|
||||
color: var(--Text-Inverted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: var(--Space-x3) 0;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
.mobileTitle {
|
||||
display: none;
|
||||
}
|
||||
.titleContainer {
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
padding: var(--Space-x6) 0;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { HotelDetailsSidePeek } from "@scandic-hotels/booking-flow/components/HotelDetailsSidePeek"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
|
||||
|
||||
export default async function HotelHeader({
|
||||
hotelData: { hotel, url, restaurants, additionalData },
|
||||
}: HotelHeaderProps) {
|
||||
const image = hotel.hotelContent?.images
|
||||
const intl = await getIntl()
|
||||
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Image
|
||||
className={styles.hero}
|
||||
alt={image.altText || image.altText_En || ""}
|
||||
src={image.src}
|
||||
height={200}
|
||||
width={1196}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title
|
||||
as="h1"
|
||||
level="h1"
|
||||
color="white"
|
||||
textAlign="center"
|
||||
className={styles.title}
|
||||
>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<Title
|
||||
as="h2"
|
||||
level="h1"
|
||||
color="white"
|
||||
className={styles.mobileTitle}
|
||||
textAlign="center"
|
||||
>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<div className={styles.address}>{addressStr}</div>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<HotelDetailsSidePeek
|
||||
hotel={{ ...hotel, url: url }}
|
||||
restaurants={restaurants}
|
||||
additionalHotelData={additionalData}
|
||||
triggerLabel={intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
buttonVariant={"secondary"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.wrapper {
|
||||
margin-top: var(--Spacing-x3);
|
||||
max-width: min(100%, 620px);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./bookingAlert.module.css"
|
||||
|
||||
function useBookingErrorAlert() {
|
||||
const updateSearchParams = useEnterDetailsStore(
|
||||
(state) => state.actions.updateSeachParamString
|
||||
)
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const errorCode = searchParams.get("errorCode")
|
||||
const errorMessage = getErrorMessage(errorCode)
|
||||
const severityLevel =
|
||||
errorCode === BookingErrorCodeEnum.TransactionCancelled
|
||||
? AlertTypeEnum.Warning
|
||||
: AlertTypeEnum.Alarm
|
||||
|
||||
const [showAlert, setShowAlert] = useState(!!errorCode)
|
||||
|
||||
const selectRateReturnUrl = getSelectRateReturnUrl()
|
||||
|
||||
useEffect(() => {
|
||||
setShowAlert(!!errorCode)
|
||||
}, [errorCode])
|
||||
|
||||
function getErrorMessage(errorCode: string | null) {
|
||||
switch (errorCode) {
|
||||
case BookingErrorCodeEnum.TransactionCancelled:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "You have now cancelled your payment.",
|
||||
})
|
||||
case BookingErrorCodeEnum.AvailabilityError:
|
||||
case BookingErrorCodeEnum.NoAvailabilityForRateAndRoomType:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
|
||||
})
|
||||
default:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function discardAlert() {
|
||||
setShowAlert(false)
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.delete("errorCode")
|
||||
updateSearchParams(queryParams.toString())
|
||||
|
||||
window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`)
|
||||
}
|
||||
|
||||
function getSelectRateReturnUrl() {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.delete("errorCode")
|
||||
return `${selectRate(lang)}?${queryParams.toString()}`
|
||||
}
|
||||
|
||||
return {
|
||||
showAlert,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
severityLevel,
|
||||
discardAlert,
|
||||
setShowAlert,
|
||||
selectRateReturnUrl,
|
||||
}
|
||||
}
|
||||
|
||||
interface BookingAlertProps {
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
export default function BookingAlert({ isVisible = false }: BookingAlertProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const {
|
||||
showAlert,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
severityLevel,
|
||||
discardAlert,
|
||||
setShowAlert,
|
||||
selectRateReturnUrl,
|
||||
} = useBookingErrorAlert()
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { getTopOffset } = useStickyPosition()
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setShowAlert(true)
|
||||
}
|
||||
}, [isVisible, setShowAlert])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (showAlert && el) {
|
||||
window.scrollTo({
|
||||
top: el.offsetTop - getTopOffset(),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [showAlert, getTopOffset])
|
||||
|
||||
if (!showAlert) return null
|
||||
|
||||
const isAvailabilityError =
|
||||
errorCode === BookingErrorCodeEnum.AvailabilityError ||
|
||||
errorCode === BookingErrorCodeEnum.NoAvailabilityForRateAndRoomType
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={ref}>
|
||||
<Alert
|
||||
type={severityLevel}
|
||||
variant="inline"
|
||||
text={errorMessage}
|
||||
close={discardAlert}
|
||||
link={
|
||||
isAvailabilityError
|
||||
? {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "Change room",
|
||||
}),
|
||||
url: selectRateReturnUrl,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content ol {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker,
|
||||
.summary::marker {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import styles from "./guaranteeDetails.module.css"
|
||||
|
||||
export default function GuaranteeDetails() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<details>
|
||||
<Caption color="burgundy" type="bold" asChild>
|
||||
<summary className={styles.summary}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "How it works",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
color="Icon/Interactive/Default"
|
||||
size={16}
|
||||
/>
|
||||
</summary>
|
||||
</Caption>
|
||||
<section className={styles.content}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"When guaranteeing your booking, we will hold the booking until 07:00 until the day after check-in. This will provide you as a guest with added flexibility for check-in times.",
|
||||
})}
|
||||
</Body>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "What you have to do to guarantee booking:",
|
||||
})}
|
||||
</Body>
|
||||
<ol>
|
||||
<Body asChild>
|
||||
<li>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Complete the booking",
|
||||
})}
|
||||
</li>
|
||||
</Body>
|
||||
<Body asChild>
|
||||
<li>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Provide a payment card in the next step",
|
||||
})}
|
||||
</li>
|
||||
</Body>
|
||||
</ol>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
|
||||
})}
|
||||
</Body>
|
||||
</section>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import React from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
|
||||
import {
|
||||
calculateTotalRoomPrice,
|
||||
hasFlexibleRate,
|
||||
hasPrepaidRate,
|
||||
} from "../helpers"
|
||||
|
||||
import styles from "./mixedRatePaymentBreakdown.module.css"
|
||||
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
|
||||
type PaymentBreakdownState = {
|
||||
roomsWithPrepaidRate: number[]
|
||||
roomsWithFlexRate: number[]
|
||||
payNowPrice: number
|
||||
payNowComparisonPrice: number
|
||||
payAtCheckInPrice: number
|
||||
payAtCheckInComparisonPrice: number
|
||||
}
|
||||
|
||||
interface MixedRatePaymentBreakdownProps {
|
||||
rooms: RoomState[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
export default function MixedRatePaymentBreakdown({
|
||||
rooms,
|
||||
currency,
|
||||
}: MixedRatePaymentBreakdownProps) {
|
||||
const intl = useIntl()
|
||||
const payNowTitle = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
const payAtCheckInTitle = intl.formatMessage({
|
||||
defaultMessage: "Pay at check-in",
|
||||
})
|
||||
|
||||
const initialState: PaymentBreakdownState = {
|
||||
roomsWithPrepaidRate: [],
|
||||
roomsWithFlexRate: [],
|
||||
payNowPrice: 0,
|
||||
payNowComparisonPrice: 0,
|
||||
payAtCheckInPrice: 0,
|
||||
payAtCheckInComparisonPrice: 0,
|
||||
}
|
||||
|
||||
const {
|
||||
roomsWithPrepaidRate,
|
||||
roomsWithFlexRate,
|
||||
payNowPrice,
|
||||
payNowComparisonPrice,
|
||||
payAtCheckInPrice,
|
||||
payAtCheckInComparisonPrice,
|
||||
} = rooms.reduce((acc, room, idx) => {
|
||||
if (hasPrepaidRate(room)) {
|
||||
acc.roomsWithPrepaidRate.push(idx)
|
||||
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
|
||||
acc.payNowPrice += totalPrice
|
||||
acc.payNowComparisonPrice += comparisonPrice
|
||||
}
|
||||
if (hasFlexibleRate(room)) {
|
||||
acc.roomsWithFlexRate.push(idx)
|
||||
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
|
||||
acc.payAtCheckInPrice += totalPrice
|
||||
acc.payAtCheckInComparisonPrice += comparisonPrice
|
||||
}
|
||||
return acc
|
||||
}, initialState)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PaymentCard
|
||||
title={payNowTitle}
|
||||
price={payNowPrice}
|
||||
comparisonPrice={payNowComparisonPrice}
|
||||
currency={currency}
|
||||
roomIndexes={roomsWithPrepaidRate}
|
||||
/>
|
||||
<PaymentCard
|
||||
title={payAtCheckInTitle}
|
||||
price={payAtCheckInPrice}
|
||||
comparisonPrice={payAtCheckInComparisonPrice}
|
||||
currency={currency}
|
||||
roomIndexes={roomsWithFlexRate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaymentCardProps {
|
||||
title: string
|
||||
price: number
|
||||
comparisonPrice: number
|
||||
currency: string
|
||||
roomIndexes: number[]
|
||||
}
|
||||
|
||||
function PaymentCard({
|
||||
title,
|
||||
price,
|
||||
comparisonPrice,
|
||||
currency,
|
||||
roomIndexes,
|
||||
}: PaymentCardProps) {
|
||||
const intl = useIntl()
|
||||
const isMemberRateApplied = price < comparisonPrice
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<Caption
|
||||
type="bold"
|
||||
textTransform="uppercase"
|
||||
className={styles.cardTitle}
|
||||
>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{title}{" "}
|
||||
<span>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{"/ "}
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomIndexes.map((idx) => idx + 1).join(" & "),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</Caption>
|
||||
<Body textTransform="bold" className={styles.priceItem}>
|
||||
{formatPrice(intl, price, currency)}
|
||||
{isMemberRateApplied && comparisonPrice ? (
|
||||
<span>{formatPrice(intl, comparisonPrice, currency)}</span>
|
||||
) : null}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
background-color: var(--Scandic-Blue-00);
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cardTitle > span {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.card.inactive {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.priceItem {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.priceItem > span {
|
||||
font-weight: 400;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { serializeBookingSearchParams } from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/booking"
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import { trackPaymentEvent } from "@/utils/tracking"
|
||||
|
||||
import {
|
||||
clearPaymentInfoSessionStorage,
|
||||
readPaymentInfoFromSessionStorage,
|
||||
} from "../helpers"
|
||||
import { clearGlaSessionStorage, readGlaFromSessionStorage } from "./helpers"
|
||||
|
||||
import type { PersistedState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function HandleErrorCallback({
|
||||
returnUrl,
|
||||
searchObject,
|
||||
status,
|
||||
errorMessage,
|
||||
}: {
|
||||
returnUrl: string
|
||||
searchObject: URLSearchParams
|
||||
status: PaymentCallbackStatusEnum
|
||||
errorMessage?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||
|
||||
if (bookingData) {
|
||||
const detailsStorage: PersistedState = JSON.parse(bookingData)
|
||||
const searchParams = serializeBookingSearchParams(
|
||||
detailsStorage.booking,
|
||||
{
|
||||
initialSearchParams: searchObject,
|
||||
}
|
||||
)
|
||||
|
||||
const glaSessionData = readGlaFromSessionStorage()
|
||||
const paymentInfoSessionData = readPaymentInfoFromSessionStorage()
|
||||
|
||||
if (status === PaymentCallbackStatusEnum.Cancel) {
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "glaCardSaveCancelled",
|
||||
hotelInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
status: "glacardsavecancelled",
|
||||
type: glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentCancel",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
status: "cancelled",
|
||||
method: paymentInfoSessionData?.paymentMethod,
|
||||
isSavedCreditCard: paymentInfoSessionData?.isSavedCreditCard,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (status === PaymentCallbackStatusEnum.Error) {
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "glaCardSaveFailed",
|
||||
hotelInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
status: "glacardsavefailed",
|
||||
type: glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId: detailsStorage.booking.hotelId,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
method: paymentInfoSessionData?.paymentMethod,
|
||||
isSavedCreditCard: paymentInfoSessionData?.isSavedCreditCard,
|
||||
})
|
||||
}
|
||||
}
|
||||
clearGlaSessionStorage()
|
||||
clearPaymentInfoSessionStorage()
|
||||
|
||||
if (searchParams.size > 0) {
|
||||
router.replace(`${returnUrl}?${searchParams.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [returnUrl, router, searchObject, status, errorMessage])
|
||||
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { MEMBERSHIP_FAILED_ERROR } from "@scandic-hotels/common/constants/booking"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
|
||||
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
|
||||
import TimeoutSpinner from "./TimeoutSpinner"
|
||||
import { trackGuaranteeBookingSuccess } from "./tracking"
|
||||
|
||||
const validBookingStatuses = [
|
||||
BookingStatusEnum.PaymentSucceeded,
|
||||
BookingStatusEnum.BookingCompleted,
|
||||
]
|
||||
|
||||
interface HandleStatusPollingProps {
|
||||
refId: string
|
||||
sig: string
|
||||
successRedirectUrl: string
|
||||
cardType?: string
|
||||
}
|
||||
|
||||
export default function HandleSuccessCallback({
|
||||
refId,
|
||||
sig,
|
||||
successRedirectUrl,
|
||||
cardType,
|
||||
}: HandleStatusPollingProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
}, [sig])
|
||||
|
||||
const {
|
||||
data: bookingStatus,
|
||||
error,
|
||||
isTimeout,
|
||||
} = useHandleBookingStatus({
|
||||
refId,
|
||||
expectedStatuses: validBookingStatuses,
|
||||
maxRetries: 10,
|
||||
retryInterval: 2000,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookingStatus?.booking.reservationStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
validBookingStatuses.includes(
|
||||
bookingStatus.booking.reservationStatus as BookingStatusEnum
|
||||
)
|
||||
) {
|
||||
trackGuaranteeBookingSuccess(cardType)
|
||||
// a successful booking can still have membership errors
|
||||
const membershipFailedError = bookingStatus.booking.errors.find(
|
||||
(e) => e.errorCode === MEMBERSHIP_FAILED_ERROR
|
||||
)
|
||||
const errorParam = membershipFailedError
|
||||
? `&errorCode=${membershipFailedError.errorCode}`
|
||||
: ""
|
||||
|
||||
router.replace(`${successRedirectUrl}${errorParam}`)
|
||||
}
|
||||
}, [bookingStatus, cardType, successRedirectUrl, router])
|
||||
|
||||
if (isTimeout || error) {
|
||||
return <TimeoutSpinner />
|
||||
}
|
||||
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import { customerService } from "@/constants/webHrefs"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./timeoutSpinner.module.css"
|
||||
|
||||
export default function TimeoutSpinner() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
<Subtitle className={styles.heading}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Taking longer than usual",
|
||||
})}
|
||||
</Subtitle>
|
||||
<Body textAlign="center" className={styles.messageContainer}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"We are still confirming your booking. This is usually a matter of minutes and we do apologise for the wait. Please check your inbox for a booking confirmation email and if you still haven't received it by end of day, please contact our <link>customer support</link>.",
|
||||
},
|
||||
{
|
||||
link: (text) => (
|
||||
<Link
|
||||
href={customerService[lang]}
|
||||
textDecoration="underline"
|
||||
target="_blank"
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: var(--Spacing-x2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container .heading {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.messageContainer {
|
||||
max-width: 435px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import "client-only"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
export const glaStorageName = "gla-storage"
|
||||
|
||||
type GlaSessionData = {
|
||||
lateArrivalGuarantee: string
|
||||
hotelId: string
|
||||
paymentMethod?: string
|
||||
isSavedCreditCard?: boolean
|
||||
}
|
||||
|
||||
export function readGlaFromSessionStorage(): GlaSessionData | null {
|
||||
try {
|
||||
const glaSessionData = sessionStorage.getItem(glaStorageName)
|
||||
if (!glaSessionData) return null
|
||||
return JSON.parse(glaSessionData)
|
||||
} catch (error) {
|
||||
logger.error("Error reading from session storage:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGlaToSessionStorage(
|
||||
lateArrivalGuarantee: string,
|
||||
hotelId: string,
|
||||
paymentMethod: string,
|
||||
isSavedCreditCard: boolean
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
glaStorageName,
|
||||
JSON.stringify({
|
||||
lateArrivalGuarantee,
|
||||
hotelId,
|
||||
paymentMethod,
|
||||
isSavedCreditCard,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGlaSessionStorage() {
|
||||
sessionStorage.removeItem(glaStorageName)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
|
||||
import { clearGlaSessionStorage, readGlaFromSessionStorage } from "./helpers"
|
||||
|
||||
export function trackGuaranteeBookingSuccess(cardType?: string) {
|
||||
const glaSessionData = readGlaFromSessionStorage()
|
||||
if (glaSessionData) {
|
||||
trackEvent({
|
||||
event: "guaranteeBookingSuccess",
|
||||
hotelInfo: {
|
||||
lateArrivalGuarantee: glaSessionData.lateArrivalGuarantee,
|
||||
hotelId: glaSessionData.hotelId,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
hotelId: glaSessionData.hotelId,
|
||||
type: cardType ?? glaSessionData.paymentMethod,
|
||||
isSavedCreditCard: glaSessionData.isSavedCreditCard,
|
||||
},
|
||||
})
|
||||
}
|
||||
clearGlaSessionStorage()
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Label } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
selectRate,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { trackEvent } from "@scandic-hotels/common/tracking/base"
|
||||
import { formatPhoneNumber } from "@scandic-hotels/common/utils/phone"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { PaymentOption } from "@scandic-hotels/design-system/Form/PaymentOption"
|
||||
import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import { bedTypeMap } from "@scandic-hotels/trpc/constants/bedTypeMap"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { env } from "@/env/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackPaymentEvent, trackUpdatePaymentMethod } from "@/utils/tracking"
|
||||
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
|
||||
|
||||
import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm"
|
||||
import PriceChangeDialog from "../PriceChangeDialog"
|
||||
import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
|
||||
import BookingAlert from "./BookingAlert"
|
||||
import GuaranteeDetails from "./GuaranteeDetails"
|
||||
import {
|
||||
hasFlexibleRate,
|
||||
hasPrepaidRate,
|
||||
isPaymentMethodEnum,
|
||||
writePaymentInfoToSessionStorage,
|
||||
} from "./helpers"
|
||||
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
|
||||
import { type PaymentFormData, paymentSchema } from "./schema"
|
||||
import TermsAndConditions from "./TermsAndConditions"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import type {
|
||||
PaymentClientProps,
|
||||
PriceChangeData,
|
||||
} from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
const maxRetries = 15
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
|
||||
export default function PaymentClient({
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
isUserLoggedIn,
|
||||
}: PaymentClientProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const { getTopOffset } = useStickyPosition({})
|
||||
|
||||
const [showBookingAlert, setShowBookingAlert] = useState(false)
|
||||
|
||||
const {
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isSubmitting,
|
||||
preSubmitCallbacks,
|
||||
setIsSubmitting,
|
||||
} = useEnterDetailsStore((state) => ({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
totalPrice: state.totalPrice,
|
||||
preSubmitCallbacks: state.preSubmitCallbacks,
|
||||
isSubmitting: state.isSubmitting,
|
||||
setIsSubmitting: state.actions.setIsSubmitting,
|
||||
}))
|
||||
|
||||
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => {
|
||||
if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
(room.guest.join || room.guest.membershipNo) &&
|
||||
booking.rooms[idx].counterRateCode
|
||||
) {
|
||||
return room.memberMustBeGuaranteed
|
||||
}
|
||||
|
||||
return room.mustBeGuaranteed
|
||||
})
|
||||
|
||||
const [refId, setRefId] = useState("")
|
||||
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
||||
useState(false)
|
||||
|
||||
const availablePaymentOptions =
|
||||
useAvailablePaymentOptions(otherPaymentOptions)
|
||||
const [priceChangeData, setPriceChangeData] =
|
||||
useState<PriceChangeData | null>(null)
|
||||
|
||||
const { toDate, fromDate, hotelId } = booking
|
||||
|
||||
const hasPrepaidRates = rooms.some(hasPrepaidRate)
|
||||
const hasFlexRates = rooms.some(hasFlexibleRate)
|
||||
const hasOnlyFlexRates = rooms.every(hasFlexibleRate)
|
||||
const hasMixedRates = hasPrepaidRates && hasFlexRates
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
guarantee: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
if ("error" in result) {
|
||||
const queryParams = new URLSearchParams(searchParams.toString())
|
||||
queryParams.set("errorCode", result.cause)
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${pathname}?${queryParams.toString()}`
|
||||
)
|
||||
handlePaymentError(result.cause)
|
||||
return
|
||||
}
|
||||
|
||||
const { booking } = result
|
||||
const mainRoom = booking.rooms[0]
|
||||
|
||||
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
|
||||
router.push(confirmationUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setRefId(mainRoom.refId)
|
||||
|
||||
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
|
||||
if (hasPriceChange) {
|
||||
const priceChangeData = booking.rooms.map(
|
||||
(room) => room.priceChangedMetadata || null
|
||||
)
|
||||
setPriceChangeData(priceChangeData)
|
||||
} else {
|
||||
setIsPollingForBookingStatus(true)
|
||||
}
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error("Booking error", error)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const priceChange = trpc.booking.priceChange.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.id) {
|
||||
setIsPollingForBookingStatus(true)
|
||||
} else {
|
||||
handlePaymentError("No confirmation number")
|
||||
}
|
||||
setPriceChangeData(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error("Price change error", error)
|
||||
setPriceChangeData(null)
|
||||
handlePaymentError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
refId,
|
||||
expectedStatuses: [BookingStatusEnum.BookingCompleted],
|
||||
maxRetries,
|
||||
retryInterval,
|
||||
enabled: isPollingForBookingStatus,
|
||||
})
|
||||
|
||||
const handlePaymentError = useCallback(
|
||||
(errorMessage: string) => {
|
||||
setShowBookingAlert(true)
|
||||
setIsSubmitting(false)
|
||||
|
||||
const currentPaymentMethod = methods.getValues("paymentMethod")
|
||||
const smsEnable = methods.getValues("smsConfirmation")
|
||||
const guarantee = methods.getValues("guarantee")
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === currentPaymentMethod
|
||||
)
|
||||
|
||||
const isSavedCreditCard = !!savedCreditCard
|
||||
|
||||
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
||||
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
||||
trackEvent({
|
||||
event: "glaCardSaveFailed",
|
||||
hotelInfo: {
|
||||
hotelId,
|
||||
lateArrivalGuarantee,
|
||||
guaranteedProduct: "room",
|
||||
},
|
||||
paymentInfo: {
|
||||
isSavedCreditCard,
|
||||
hotelId,
|
||||
status: "glacardsavefailed",
|
||||
type: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
trackPaymentEvent({
|
||||
event: "paymentFail",
|
||||
hotelId,
|
||||
method: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
|
||||
isSavedCreditCard,
|
||||
smsEnable,
|
||||
errorMessage,
|
||||
status: "failed",
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
methods,
|
||||
savedCreditCards,
|
||||
hotelId,
|
||||
bookingMustBeGuaranteed,
|
||||
hasOnlyFlexRates,
|
||||
setIsSubmitting,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.booking.paymentUrl) {
|
||||
router.push(bookingStatus.data.booking.paymentUrl)
|
||||
} else if (
|
||||
bookingStatus?.data?.booking.reservationStatus ===
|
||||
BookingStatusEnum.BookingCompleted
|
||||
) {
|
||||
const mainRoom = bookingStatus.data.booking.rooms[0]
|
||||
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
|
||||
document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
|
||||
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
|
||||
router.push(confirmationUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
handlePaymentError("Timeout")
|
||||
}
|
||||
}, [
|
||||
bookingStatus.data,
|
||||
bookingStatus.isTimeout,
|
||||
router,
|
||||
intl,
|
||||
lang,
|
||||
handlePaymentError,
|
||||
])
|
||||
|
||||
const getPaymentMethod = useCallback(
|
||||
(paymentMethod: string | null | undefined): PaymentMethodEnum => {
|
||||
if (hasFlexRates) {
|
||||
return PaymentMethodEnum.card
|
||||
}
|
||||
return paymentMethod && isPaymentMethodEnum(paymentMethod)
|
||||
? paymentMethod
|
||||
: PaymentMethodEnum.card
|
||||
},
|
||||
[hasFlexRates]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
Object.values(preSubmitCallbacks).forEach((callback) => {
|
||||
callback()
|
||||
})
|
||||
const firstIncompleteRoomIndex = rooms.findIndex(
|
||||
(room) => !room.isComplete
|
||||
)
|
||||
|
||||
// If any room is not complete/valid, scroll to it
|
||||
if (firstIncompleteRoomIndex !== -1) {
|
||||
const roomElement = document.getElementById(
|
||||
`room-${firstIncompleteRoomIndex + 1}`
|
||||
)
|
||||
|
||||
if (!roomElement) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
const roomElementTop =
|
||||
roomElement.getBoundingClientRect().top + window.scrollY
|
||||
|
||||
window.scrollTo({
|
||||
top: roomElementTop - getTopOffset() - 20,
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const paymentMethod = getPaymentMethod(data.paymentMethod)
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
|
||||
const guarantee = data.guarantee
|
||||
const useSavedCard = savedCreditCard
|
||||
? {
|
||||
card: {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
},
|
||||
}
|
||||
: {}
|
||||
|
||||
const shouldUsePayment =
|
||||
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
|
||||
const payment = shouldUsePayment
|
||||
? {
|
||||
paymentMethod: paymentMethod,
|
||||
...useSavedCard,
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
error: `${paymentRedirectUrl}/error`,
|
||||
cancel: `${paymentRedirectUrl}/cancel`,
|
||||
}
|
||||
: undefined
|
||||
const paymentMethodType = savedCreditCard
|
||||
? savedCreditCard.type
|
||||
: paymentMethod
|
||||
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
||||
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
||||
writeGlaToSessionStorage(
|
||||
lateArrivalGuarantee,
|
||||
hotelId,
|
||||
paymentMethodType,
|
||||
!!savedCreditCard
|
||||
)
|
||||
trackGlaSaveCardAttempt(hotelId, savedCreditCard, lateArrivalGuarantee)
|
||||
} else if (!hasOnlyFlexRates) {
|
||||
trackPaymentEvent({
|
||||
event: "paymentAttemptStart",
|
||||
hotelId,
|
||||
method: paymentMethodType,
|
||||
isSavedCreditCard: !!savedCreditCard,
|
||||
smsEnable: data.smsConfirmation,
|
||||
status: "attempt",
|
||||
})
|
||||
}
|
||||
writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard)
|
||||
|
||||
const payload = {
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
hotelId,
|
||||
language: lang,
|
||||
payment,
|
||||
rooms: rooms.map(({ room }, idx) => {
|
||||
const isMainRoom = idx === 0
|
||||
let rateCode = ""
|
||||
if (isMainRoom && isUserLoggedIn) {
|
||||
rateCode = booking.rooms[idx].rateCode
|
||||
} else if (
|
||||
(room.guest.join || room.guest.membershipNo) &&
|
||||
booking.rooms[idx].counterRateCode
|
||||
) {
|
||||
rateCode = booking.rooms[idx].counterRateCode
|
||||
} else {
|
||||
rateCode = booking.rooms[idx].rateCode
|
||||
}
|
||||
|
||||
const phoneNumber = formatPhoneNumber(
|
||||
room.guest.phoneNumber,
|
||||
room.guest.phoneNumberCC
|
||||
)
|
||||
|
||||
return {
|
||||
adults: room.adults,
|
||||
bookingCode: room.roomRate.bookingCode,
|
||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
guest: {
|
||||
becomeMember: room.guest.join,
|
||||
countryCode: room.guest.countryCode,
|
||||
email: room.guest.email,
|
||||
firstName: room.guest.firstName,
|
||||
lastName: room.guest.lastName,
|
||||
membershipNumber: room.guest.membershipNo,
|
||||
phoneNumber,
|
||||
// Only allowed for room one
|
||||
...(idx === 0 && {
|
||||
dateOfBirth:
|
||||
"dateOfBirth" in room.guest && room.guest.dateOfBirth
|
||||
? room.guest.dateOfBirth
|
||||
: undefined,
|
||||
postalCode:
|
||||
"zipCode" in room.guest && room.guest.zipCode
|
||||
? room.guest.zipCode
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
packages: {
|
||||
accessibility:
|
||||
room.roomFeatures?.some(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
) ?? false,
|
||||
allergyFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
) ?? false,
|
||||
breakfast: !!(room.breakfast && room.breakfast.code),
|
||||
petFriendly:
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
) ?? false,
|
||||
},
|
||||
rateCode,
|
||||
roomPrice: {
|
||||
memberPrice:
|
||||
"member" in room.roomRate
|
||||
? room.roomRate.member?.localPrice.pricePerStay
|
||||
: undefined,
|
||||
publicPrice:
|
||||
"public" in room.roomRate
|
||||
? room.roomRate.public?.localPrice.pricePerStay
|
||||
: undefined,
|
||||
},
|
||||
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
specialRequest: {
|
||||
comment: room.specialRequest.comment
|
||||
? room.specialRequest.comment
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
initiateBooking.mutate(payload)
|
||||
},
|
||||
[
|
||||
savedCreditCards,
|
||||
lang,
|
||||
initiateBooking,
|
||||
hotelId,
|
||||
fromDate,
|
||||
toDate,
|
||||
rooms,
|
||||
booking.rooms,
|
||||
getPaymentMethod,
|
||||
hasOnlyFlexRates,
|
||||
bookingMustBeGuaranteed,
|
||||
preSubmitCallbacks,
|
||||
isUserLoggedIn,
|
||||
getTopOffset,
|
||||
setIsSubmitting,
|
||||
]
|
||||
)
|
||||
|
||||
const finalStep = intl.formatMessage({ defaultMessage: "Final step" })
|
||||
const selectPayment = intl.formatMessage({
|
||||
defaultMessage: "Select payment method",
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(styles.paymentSection, {
|
||||
[styles.disabled]: isSubmitting,
|
||||
})}
|
||||
>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<span>{hasOnlyFlexRates ? finalStep : selectPayment}</span>
|
||||
</Typography>
|
||||
<BookingAlert isVisible={showBookingAlert} />
|
||||
</header>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
id={formId}
|
||||
>
|
||||
{booking.searchType === SEARCH_TYPE_REDEMPTION ? (
|
||||
<ConfirmBookingRedemption />
|
||||
) : hasOnlyFlexRates && !bookingMustBeGuaranteed ? (
|
||||
<ConfirmBooking savedCreditCards={savedCreditCards} />
|
||||
) : (
|
||||
<>
|
||||
{hasOnlyFlexRates && bookingMustBeGuaranteed ? (
|
||||
<section className={styles.section}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
|
||||
})}
|
||||
</Body>
|
||||
<GuaranteeDetails />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasMixedRates ? (
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.",
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
|
||||
<section className={styles.section}>
|
||||
<PaymentOptionsGroup
|
||||
name="paymentMethod"
|
||||
className={styles.paymentOptionContainer}
|
||||
onChange={(method) => trackUpdatePaymentMethod({ method })}
|
||||
>
|
||||
<Label className="sr-only">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Payment methods",
|
||||
})}
|
||||
</Label>
|
||||
|
||||
{savedCreditCards?.length ? (
|
||||
<>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "MY SAVED CARDS",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
{savedCreditCards.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
value={savedCreditCard.id as PaymentMethodEnum}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "OTHER PAYMENT METHODS",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<PaymentOption
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Credit card",
|
||||
})}
|
||||
/>
|
||||
{!hasMixedRates &&
|
||||
availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
paymentMethod as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PaymentOptionsGroup>
|
||||
{hasMixedRates ? (
|
||||
<MixedRatePaymentBreakdown
|
||||
rooms={rooms}
|
||||
currency={totalPrice.local.currency}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className={styles.checkboxContainer}>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<TermsAndConditions isFlexBookingTerms={hasOnlyFlexRates} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.submitButton}>
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Complete booking",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{priceChangeData ? (
|
||||
<PriceChangeDialog
|
||||
isOpen={!!priceChangeData}
|
||||
priceChangeData={priceChangeData}
|
||||
prevTotalPrice={totalPrice.local.price}
|
||||
currency={totalPrice.local.currency}
|
||||
onCancel={() => {
|
||||
const allSearchParams = searchParams.size
|
||||
? `?${searchParams.toString()}`
|
||||
: ""
|
||||
router.push(`${selectRate(lang)}${allSearchParams}`)
|
||||
}}
|
||||
onAccept={() => priceChange.mutate({ refId })}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { bookingTermsAndConditions, privacyPolicy } from "@/constants/webHrefs"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "../payment.module.css"
|
||||
|
||||
import type { TermsAndConditionsProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
export default function TermsAndConditions({
|
||||
isFlexBookingTerms,
|
||||
}: TermsAndConditionsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Caption>
|
||||
{isFlexBookingTerms
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic requires a valid payment card during my visit in case anything is left unpaid.",
|
||||
},
|
||||
{
|
||||
termsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyPolicyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
textDecoration="underline"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
size="small"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Checkbox name="termsAndConditions">
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "I accept the terms and conditions",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Checkbox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
|
||||
export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values<string>(PaymentMethodEnum).includes(value)
|
||||
}
|
||||
|
||||
export function hasFlexibleRate({ room }: RoomState): boolean {
|
||||
return room.isFlexRate
|
||||
}
|
||||
|
||||
export function hasPrepaidRate({ room }: RoomState): boolean {
|
||||
return !room.isFlexRate
|
||||
}
|
||||
|
||||
export function calculateTotalRoomPrice(
|
||||
{ room }: RoomState,
|
||||
initialRoomPrice?: number
|
||||
) {
|
||||
let totalPrice = initialRoomPrice ?? room.roomPrice.perStay.local.price
|
||||
|
||||
if (room.breakfast) {
|
||||
totalPrice += Number(room.breakfast.localPrice.totalPrice) * room.adults
|
||||
}
|
||||
|
||||
if (room.roomFeatures) {
|
||||
room.roomFeatures.forEach((pkg) => {
|
||||
totalPrice += Number(pkg.localPrice.price)
|
||||
})
|
||||
}
|
||||
|
||||
let comparisonPrice = totalPrice
|
||||
|
||||
const isMember = room.guest.join || room.guest.membershipNo
|
||||
if (isMember && "member" in room.roomRate) {
|
||||
const publicPrice = room.roomRate.public?.localPrice.pricePerStay ?? 0
|
||||
const memberPrice = room.roomRate.member?.localPrice.pricePerStay ?? 0
|
||||
const diff = publicPrice - memberPrice
|
||||
comparisonPrice = totalPrice + diff
|
||||
}
|
||||
|
||||
return {
|
||||
totalPrice,
|
||||
comparisonPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentInfoStorageName = "payment-info-storage"
|
||||
|
||||
type PaymentInfoSessionData = {
|
||||
paymentMethod: string
|
||||
isSavedCreditCard: boolean
|
||||
}
|
||||
|
||||
export function readPaymentInfoFromSessionStorage():
|
||||
| PaymentInfoSessionData
|
||||
| undefined {
|
||||
try {
|
||||
const paymentInfoSessionData = sessionStorage.getItem(
|
||||
paymentInfoStorageName
|
||||
)
|
||||
if (!paymentInfoSessionData) return undefined
|
||||
return JSON.parse(paymentInfoSessionData)
|
||||
} catch (error) {
|
||||
logger.error("Error reading from session storage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaymentInfoToSessionStorage(
|
||||
paymentMethod: string,
|
||||
isSavedCreditCard: boolean
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
paymentInfoStorageName,
|
||||
JSON.stringify({
|
||||
paymentMethod,
|
||||
isSavedCreditCard,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPaymentInfoSessionStorage() {
|
||||
sessionStorage.removeItem(paymentInfoStorageName)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { isLoggedInUser } from "@/utils/isLoggedInUser"
|
||||
|
||||
import PaymentClient from "./PaymentClient"
|
||||
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
export default async function Payment({
|
||||
otherPaymentOptions,
|
||||
supportedCards,
|
||||
}: PaymentProps) {
|
||||
const savedCreditCards = await getSavedPaymentCardsSafely({
|
||||
supportedCards,
|
||||
})
|
||||
const isUserLoggedIn = await isLoggedInUser()
|
||||
|
||||
return (
|
||||
<PaymentClient
|
||||
otherPaymentOptions={otherPaymentOptions}
|
||||
savedCreditCards={savedCreditCards}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.paymentSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
max-width: 696px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
font-weight: 500;
|
||||
font-size: var(--Typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
background-color: var(--Surface-Secondary-Default);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.submitButton {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.paymentContainer {
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.string().nullish(),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
guarantee: z.boolean(),
|
||||
})
|
||||
|
||||
export interface PaymentFormData extends z.output<typeof paymentSchema> {}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { getFeatureDescription } from "@scandic-hotels/booking-flow/utils/getRoomFeatureDescription"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import styles from "./priceChangeSummary.module.css"
|
||||
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
|
||||
interface PriceChangeSummaryProps {
|
||||
rooms: RoomState[]
|
||||
roomPrices: { prevPrice: number; newPrice?: number }[]
|
||||
newTotalPrice: { price: number; currency: string }
|
||||
onAccept: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function PriceChangeSummary({
|
||||
rooms,
|
||||
roomPrices,
|
||||
newTotalPrice,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: PriceChangeSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, toggleOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={() => toggleOpen((isOpen) => !isOpen)}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See price details",
|
||||
})}
|
||||
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
||||
</Button>
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={toggleOpen}>
|
||||
<Modal>
|
||||
<Dialog className={styles.dialog}>
|
||||
{({ close }) => (
|
||||
<div className={styles.content}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Price details",
|
||||
})}
|
||||
</Subtitle>
|
||||
<Button
|
||||
onPress={close}
|
||||
variant="clean"
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<MaterialIcon icon="close" size={20} color="CurrentColor" />
|
||||
</Button>
|
||||
</header>
|
||||
<section>
|
||||
<div>
|
||||
{rooms.map(({ room }, idx) => {
|
||||
const roomNumber = idx + 1
|
||||
const newPrice = roomPrices[idx].newPrice
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div className={styles.rowContainer}>
|
||||
<Body textTransform="bold">
|
||||
{rooms.length > 1
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomNumber }
|
||||
)
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Room",
|
||||
})}
|
||||
</Body>
|
||||
<Body>{room.roomType}</Body>
|
||||
<div className={styles.priceRow}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room charge",
|
||||
})}
|
||||
</Caption>
|
||||
{newPrice ? (
|
||||
<div className={styles.updatedPrice}>
|
||||
<Caption color="uiTextMediumContrast" striked>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
<Body
|
||||
color="uiTextMediumContrast"
|
||||
textTransform="bold"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
newPrice,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
{room.breakfast && (
|
||||
<div className={styles.priceRow}>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Breakfast charge",
|
||||
})}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
Number(
|
||||
room.breakfast.localPrice.totalPrice
|
||||
),
|
||||
room.breakfast.localPrice.currency
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
)}
|
||||
{room.roomFeatures?.map((feature) => (
|
||||
<div
|
||||
className={styles.priceRow}
|
||||
key={feature.itemCode}
|
||||
>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getFeatureDescription(
|
||||
feature.code,
|
||||
feature.description,
|
||||
intl
|
||||
)}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
Number(feature.localPrice.totalPrice),
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.rowContainer}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total",
|
||||
})}
|
||||
</Body>
|
||||
<div className={styles.priceRow}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Price including VAT",
|
||||
})}
|
||||
</Body>
|
||||
<Body textTransform="bold">
|
||||
{formatPrice(
|
||||
intl,
|
||||
newTotalPrice.price,
|
||||
newTotalPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer className={styles.footer}>
|
||||
<Button intent="secondary" onClick={onCancel}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Back to select room",
|
||||
})}
|
||||
</Button>
|
||||
<Button onClick={onAccept}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue with new price",
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
background-color: var(--Background-Primary);
|
||||
z-index: 200;
|
||||
overflow: auto;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--Spacing-x4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: var(--Spacing-x4);
|
||||
right: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.roomsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rowContainer {
|
||||
padding: var(--Spacing-x2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.roomContainer:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.roomContainer:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.updatedPrice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-top: var(--Spacing-x6);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.dialog {
|
||||
padding: var(--Spacing-x6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 512px;
|
||||
height: fit-content;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: row;
|
||||
padding: var(--Spacing-x6) 0;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { calculateTotalRoomPrice } from "../Payment/helpers"
|
||||
import PriceChangeSummary from "./PriceChangeSummary"
|
||||
|
||||
import styles from "./priceChangeDialog.module.css"
|
||||
|
||||
import type { PriceChangeData } from "@/types/components/hotelReservation/enterDetails/payment"
|
||||
|
||||
type PriceDetailsState = {
|
||||
newTotalPrice: number
|
||||
roomPrices: { prevPrice: number; newPrice?: number }[]
|
||||
}
|
||||
|
||||
type PriceChangeDialogProps = {
|
||||
isOpen: boolean
|
||||
priceChangeData: PriceChangeData
|
||||
prevTotalPrice: number
|
||||
currency: string
|
||||
onCancel: () => void
|
||||
onAccept: () => void
|
||||
}
|
||||
|
||||
export default function PriceChangeDialog({
|
||||
isOpen,
|
||||
priceChangeData,
|
||||
prevTotalPrice,
|
||||
currency,
|
||||
onCancel,
|
||||
onAccept,
|
||||
}: PriceChangeDialogProps) {
|
||||
const intl = useIntl()
|
||||
const title = intl.formatMessage({
|
||||
defaultMessage: "Price change",
|
||||
})
|
||||
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||
|
||||
const { newTotalPrice, roomPrices } = rooms.reduce<PriceDetailsState>(
|
||||
(acc, room, idx) => {
|
||||
const roomPrice = room.room.roomPrice.perStay.local.price
|
||||
const priceChange = priceChangeData[idx]
|
||||
|
||||
const { totalPrice } = calculateTotalRoomPrice(
|
||||
room,
|
||||
priceChange?.roomPrice
|
||||
)
|
||||
acc.newTotalPrice += totalPrice
|
||||
|
||||
acc.roomPrices.push({
|
||||
prevPrice: roomPrice,
|
||||
newPrice: priceChange?.roomPrice,
|
||||
})
|
||||
|
||||
return acc
|
||||
},
|
||||
{ newTotalPrice: 0, roomPrices: [] }
|
||||
)
|
||||
|
||||
const roomSelectionMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalRooms, plural, one {room} other {rooms}}",
|
||||
},
|
||||
{
|
||||
totalRooms: rooms.length,
|
||||
}
|
||||
)
|
||||
|
||||
const newRoomSelectionMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalRooms, plural, one {a new room} other {new rooms}}",
|
||||
},
|
||||
{
|
||||
totalRooms: rooms.length,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={isOpen}
|
||||
isKeyboardDismissDisabled
|
||||
>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog aria-label={title} className={styles.dialog}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleContainer}>
|
||||
<MaterialIcon
|
||||
icon="info"
|
||||
size={48}
|
||||
color="Icon/Interactive/Default"
|
||||
/>
|
||||
<Title
|
||||
level="h1"
|
||||
as="h3"
|
||||
textAlign="center"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
</div>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"Prices have increased since you selected your {roomSelection}.{linebreak} To continue your booking, accept the updated price,{linebreak} or go back to select {newRoomSelection}.",
|
||||
},
|
||||
{
|
||||
roomSelection: roomSelectionMsg,
|
||||
newRoomSelection: newRoomSelectionMsg,
|
||||
linebreak: <br />,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<div>
|
||||
<Subtitle textAlign="center" color="burgundy">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "New total",
|
||||
})}
|
||||
</Subtitle>
|
||||
<div className={styles.prices}>
|
||||
<Caption striked>
|
||||
{formatPrice(intl, prevTotalPrice, currency)}
|
||||
</Caption>
|
||||
<Body textAlign="center" textTransform="bold">
|
||||
{formatPrice(intl, newTotalPrice, currency)}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<PriceChangeSummary
|
||||
rooms={rooms}
|
||||
roomPrices={roomPrices}
|
||||
newTotalPrice={{ price: newTotalPrice, currency }}
|
||||
onAccept={onAccept}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</header>
|
||||
<footer className={styles.footer}>
|
||||
<Button intent="secondary" onClick={onCancel}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Back to select room",
|
||||
})}
|
||||
</Button>
|
||||
<Button onClick={onAccept}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue with new price",
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
background: var(--Overlay-60);
|
||||
height: var(--visual-viewport-height);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
&[data-entering] {
|
||||
animation: slide-up 200ms;
|
||||
}
|
||||
&[data-exiting] {
|
||||
animation: slide-up 200ms reverse ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: var(--Scandic-Brand-Pale-Peach);
|
||||
border-top-left-radius: var(--Corner-radius-md);
|
||||
border-top-right-radius: var(--Corner-radius-md);
|
||||
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
padding: var(--Spacing-x5) var(--Spacing-x4);
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.modal .prices {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
padding-top: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.overlay {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
padding: var(--Spacing-x6);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 512px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.header {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import styles from "./header.module.css"
|
||||
|
||||
export default function Header({ children }: React.PropsWithChildren) {
|
||||
return <header className={styles.header}>{children}</header>
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details/Multiroom"
|
||||
import Header from "@/components/HotelReservation/EnterDetails/Room/Header"
|
||||
import Section from "@/components/HotelReservation/EnterDetails/Section"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
|
||||
import { getBedTypeInfoText } from "./utils"
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function Multiroom() {
|
||||
const intl = useIntl()
|
||||
const { room, roomNr } = useRoomContext()
|
||||
const breakfastPackages = useEnterDetailsStore(
|
||||
(state) => state.breakfastPackages
|
||||
)
|
||||
|
||||
const showBreakfastStep =
|
||||
!room.breakfastIncluded && !!breakfastPackages.length
|
||||
|
||||
const hasChildWithExtraBed = room.childrenInRoom?.some(
|
||||
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
|
||||
)
|
||||
|
||||
const bedTypeInfoText = getBedTypeInfoText(
|
||||
intl,
|
||||
!!hasChildWithExtraBed,
|
||||
room.bedTypes.length > 1
|
||||
)
|
||||
|
||||
return (
|
||||
<section id={`room-${roomNr}`}>
|
||||
<Header>
|
||||
<Title level="h2" as="h4">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNr,
|
||||
}
|
||||
)}
|
||||
</Title>
|
||||
</Header>
|
||||
|
||||
<SelectedRoom />
|
||||
|
||||
{room.bedTypes.length ? (
|
||||
<Section
|
||||
header={intl.formatMessage({ defaultMessage: "Bed preference" })}
|
||||
label={intl.formatMessage({ defaultMessage: "Preferred bed type" })}
|
||||
additionalInfo={bedTypeInfoText}
|
||||
step={StepEnum.selectBed}
|
||||
>
|
||||
<BedType />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{showBreakfastStep ? (
|
||||
<Section
|
||||
header={intl.formatMessage({
|
||||
defaultMessage: "Breakfast",
|
||||
})}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Select breakfast options",
|
||||
})}
|
||||
step={StepEnum.breakfast}
|
||||
>
|
||||
<Breakfast />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
header={intl.formatMessage({
|
||||
defaultMessage: "Details",
|
||||
})}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Contact details",
|
||||
})}
|
||||
>
|
||||
<Details />
|
||||
</Section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details/RoomOne"
|
||||
import Header from "@/components/HotelReservation/EnterDetails/Room/Header"
|
||||
import Section from "@/components/HotelReservation/EnterDetails/Section"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
|
||||
import { getBedTypeInfoText } from "./utils"
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
export default function RoomOne({ user }: { user: SafeUser }) {
|
||||
const intl = useIntl()
|
||||
const { room } = useRoomContext()
|
||||
const { breakfastPackages, isMultiroom } = useEnterDetailsStore((state) => ({
|
||||
breakfastPackages: state.breakfastPackages,
|
||||
isMultiroom: state.rooms.length > 1,
|
||||
}))
|
||||
|
||||
const hasChildWithExtraBed = room.childrenInRoom?.some(
|
||||
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
|
||||
)
|
||||
|
||||
const bedTypeInfoText = getBedTypeInfoText(
|
||||
intl,
|
||||
!!hasChildWithExtraBed,
|
||||
room.bedTypes.length > 1
|
||||
)
|
||||
|
||||
const showBreakfastStep =
|
||||
!room.breakfastIncluded && !!breakfastPackages.length
|
||||
|
||||
return (
|
||||
<section id="room-1">
|
||||
{isMultiroom ? (
|
||||
<Header>
|
||||
<Title level="h2" as="h4">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: 1,
|
||||
}
|
||||
)}
|
||||
</Title>
|
||||
</Header>
|
||||
) : null}
|
||||
|
||||
<SelectedRoom />
|
||||
|
||||
{room.bedTypes ? (
|
||||
<Section
|
||||
header={intl.formatMessage({ defaultMessage: "Bed preference" })}
|
||||
label={intl.formatMessage({ defaultMessage: "Preferred bed type" })}
|
||||
additionalInfo={bedTypeInfoText}
|
||||
step={StepEnum.selectBed}
|
||||
>
|
||||
<BedType />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{showBreakfastStep ? (
|
||||
<Section
|
||||
header={intl.formatMessage({
|
||||
defaultMessage: "Breakfast",
|
||||
})}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Select breakfast options",
|
||||
})}
|
||||
step={StepEnum.breakfast}
|
||||
>
|
||||
<Breakfast />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
header={intl.formatMessage({
|
||||
defaultMessage: "Details",
|
||||
})}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Contact details",
|
||||
})}
|
||||
>
|
||||
<Details user={user} />
|
||||
</Section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
export function getBedTypeInfoText(
|
||||
intl: IntlShape,
|
||||
hasChildWithExtraBed: boolean,
|
||||
hasMultipleBedTypes: boolean
|
||||
) {
|
||||
const availabilityText = intl.formatMessage({
|
||||
defaultMessage: "Subject to availability",
|
||||
})
|
||||
|
||||
const extraBedText = intl.formatMessage({
|
||||
defaultMessage: "Extra bed will be provided additionally",
|
||||
})
|
||||
|
||||
if (hasMultipleBedTypes && hasChildWithExtraBed) {
|
||||
return `${availabilityText}. ${extraBedText}`
|
||||
} else if (hasMultipleBedTypes) {
|
||||
return availabilityText
|
||||
} else if (hasChildWithExtraBed) {
|
||||
return extraBedText
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
|
||||
import styles from "./section.module.css"
|
||||
|
||||
import type { SectionProps } from "@/types/components/hotelReservation/enterDetails/section"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function Section({
|
||||
children,
|
||||
header,
|
||||
label,
|
||||
additionalInfo,
|
||||
step,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<SectionProps>) {
|
||||
const intl = useIntl()
|
||||
|
||||
const {
|
||||
room: { bedType, breakfast },
|
||||
} = useRoomContext()
|
||||
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({
|
||||
defaultMessage: "No breakfast",
|
||||
})
|
||||
const breakfastTitle = intl.formatMessage({
|
||||
defaultMessage: "Breakfast buffet",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
setTitle(bedType.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && breakfast !== undefined) {
|
||||
if (breakfast === false) {
|
||||
setTitle(noBreakfastTitle)
|
||||
} else {
|
||||
setTitle(breakfastTitle)
|
||||
}
|
||||
}
|
||||
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.section} ${disabled ? styles.disabled : ""}`}
|
||||
data-step={step}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<h2 data-at-id="details-section-heading" className={styles.heading}>
|
||||
{header}
|
||||
</h2>
|
||||
</Typography>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p
|
||||
data-at-id="details-section-subheading"
|
||||
className={styles.subheading}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</Typography>
|
||||
{additionalInfo ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.additionalInfo}>{additionalInfo}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.contentWrapper}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.section {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
width: 100%;
|
||||
padding-top: var(--Space-x3);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.subheading {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.additionalInfo {
|
||||
color: var(--Text-Secondary);
|
||||
margin-top: var(--Space-x05);
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.content {
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomDetailsSidePeek } from "@scandic-hotels/booking-flow/components/RoomDetailsSidePeek"
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { useRoomContext } from "@/contexts/Details/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
export default function SelectedRoom() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { room, idx } = useRoomContext()
|
||||
const { hotelId, roomCategories, searchParamsStr } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
hotelId: state.booking.hotelId,
|
||||
roomCategories: state.roomCategories,
|
||||
searchParamsStr: state.searchParamString,
|
||||
})
|
||||
)
|
||||
|
||||
function changeRoom() {
|
||||
const searchParams = new URLSearchParams(searchParamsStr)
|
||||
|
||||
searchParams.set("activeRoomIndex", `${idx}`)
|
||||
startTransition(() => {
|
||||
router.push(`${selectRate(lang)}?${searchParams.toString()}`)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedRoom = getHotelRoom(roomCategories, room.roomTypeCode)
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-available={room.isAvailable}>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
<h2>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room",
|
||||
})}
|
||||
</h2>
|
||||
</Footnote>
|
||||
<Subtitle
|
||||
type="two"
|
||||
className={styles.description}
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{roomType} <rate>{rateDescription}</rate>",
|
||||
},
|
||||
{
|
||||
roomType: room.roomType,
|
||||
rateDescription: room.cancellationText,
|
||||
rate: ([str]) => {
|
||||
return str ? <span className={styles.rate}>{str}</span> : null
|
||||
},
|
||||
}
|
||||
)}
|
||||
</Subtitle>
|
||||
<Button
|
||||
variant="Text"
|
||||
size="Small"
|
||||
onPress={changeRoom}
|
||||
isDisabled={isPending}
|
||||
wrapping={false}
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
<MaterialIcon icon="edit_square" size={20} color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Change",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
{room.roomTypeCode && selectedRoom && (
|
||||
<div className={styles.details}>
|
||||
<RoomDetailsSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
room={selectedRoom}
|
||||
buttonVariant="primary"
|
||||
triggerLabel={intl.formatMessage({
|
||||
defaultMessage: "See room details",
|
||||
})}
|
||||
wrapping={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.wrapper[data-available="false"] .title,
|
||||
.wrapper[data-available="false"] .description,
|
||||
.wrapper[data-available="false"] .details {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
grid-template-areas:
|
||||
"title title"
|
||||
"description button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
.button {
|
||||
grid-area: button;
|
||||
justify-self: flex-end;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.rate {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--Space-x05);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.rate {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.rate::before {
|
||||
content: "(";
|
||||
}
|
||||
.rate::after {
|
||||
content: ")";
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { hotelreservation } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
/**
|
||||
* Cleanup component to make sure no stale data is left
|
||||
* from previous booking when user is not in the booking
|
||||
* flow anymore
|
||||
*/
|
||||
export default function StorageCleaner() {
|
||||
const lang = useLang()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname.startsWith(hotelreservation(lang))) {
|
||||
sessionStorage.removeItem(detailsStorageName)
|
||||
}
|
||||
}, [lang, pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user