Merged in chore/move-enter-details (pull request #2778)

Chore/move enter details

Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-09-11 07:16:24 +00:00
parent 15711cb3a4
commit 7dee6d5083
238 changed files with 1656 additions and 1602 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { z } from "zod"
export const bedTypeFormSchema = z.object({
bedType: z.string(),
})

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { z } from "zod"
export const breakfastFormSchema = z.object({
breakfast: z.string().or(z.literal("false")),
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"],
}
)
}

View File

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

View File

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

View File

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

View File

@@ -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: " *";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
.wrapper {
margin-top: var(--Spacing-x3);
max-width: min(100%, 620px);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.header {
padding-bottom: var(--Spacing-x3);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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