Merge master

This commit is contained in:
Linus Flood
2024-11-28 13:37:45 +01:00
225 changed files with 4488 additions and 3192 deletions

View File

@@ -52,5 +52,11 @@ GOOGLE_STATIC_MAP_ID=""
GOOGLE_DYNAMIC_MAP_ID=""
HIDE_FOR_NEXT_RELEASE="false"
ENABLE_BOOKING_FLOW="false"
ENABLE_BOOKING_WIDGET="false"
ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false"
SHOW_SITE_WIDE_ALERT="false"
SHOW_SIGNUP_FLOW="true"
USE_NEW_REWARDS_ENDPOINT="true"

View File

@@ -46,3 +46,7 @@ SALESFORCE_PREFERENCE_BASE_URL="test"
USE_NEW_REWARDS_ENDPOINT="true"
TZ=UTC
ENABLE_BOOKING_FLOW="false"
ENABLE_BOOKING_WIDGET="false"
ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false"
SHOW_SITE_WIDE_ALERT="false"

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
import { isSignupPage } from "@/constants/routes/signup"
import { env } from "@/env/server"
import { getHotelPage } from "@/lib/trpc/memoizedRequests"
import HotelPage from "@/components/ContentType/HotelPage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
@@ -19,7 +20,7 @@ import {
export { generateMetadata } from "@/utils/generateMetadata"
export default function ContentTypePage({
export default async function ContentTypePage({
params,
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
setLang(params.lang)
@@ -57,7 +58,12 @@ export default function ContentTypePage({
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <HotelPage />
const hotelPageData = await getHotelPage()
return hotelPageData ? (
<HotelPage hotelId={hotelPageData.hotel_page_id} />
) : (
notFound()
)
default:
const type: never = params.contentType
console.error(`Unsupported content type given: ${type}`)

View File

@@ -1,6 +1,8 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
import Details from "@/components/HotelReservation/BookingConfirmation/Details"
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
import TotalPrice from "@/components/HotelReservation/BookingConfirmation/TotalPrice"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
@@ -12,11 +14,14 @@ export default async function BookingConfirmationPage({
searchParams,
}: PageArgs<LangParams, { confirmationNumber: string }>) {
setLang(params.lang)
const confirmationNumber = searchParams.confirmationNumber
void getBookingConfirmation(confirmationNumber)
void getBookingConfirmation(searchParams.confirmationNumber)
const { confirmationNumber } = searchParams
return (
<main className={styles.main}>
<BookingConfirmation confirmationNumber={confirmationNumber} />
</main>
<div className={styles.main}>
<Header confirmationNumber={confirmationNumber} />
<Details confirmationNumber={confirmationNumber} />
<TotalPrice confirmationNumber={confirmationNumber} />
</div>
)
}

View File

@@ -8,8 +8,5 @@ import styles from "./layout.module.css"
export default function ConfirmedBookingLayout({
children,
}: React.PropsWithChildren) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -9,8 +9,5 @@ import { LangParams, LayoutArgs } from "@/types/params"
export default function PaymentCallbackLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -42,12 +42,12 @@ export default async function PaymentCallbackPage({
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
searchObject.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
searchObject.set(
"errorCode",
bookingStatus?.metadata?.errorCode
? bookingStatus.metadata.errorCode.toString()
: PaymentErrorCodeEnum.Failed.toString()
)
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`

View File

@@ -1 +1,21 @@
export { default } from "../page"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import SidePeek from "@/components/HotelReservation/SidePeek"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HotelSidePeek({
params,
searchParams,
}: PageArgs<LangParams, { hotel: string }>) {
if (!searchParams.hotel) {
return <SidePeek hotel={null} />
}
const hotel = await getHotelData({
hotelId: searchParams.hotel,
language: params.lang,
})
return <SidePeek hotel={hotel} />
}

View File

@@ -1,25 +1,3 @@
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import SidePeek from "@/components/HotelReservation/SidePeek"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HotelSidePeek({
params,
searchParams,
}: PageArgs<LangParams, { hotel: string }>) {
const search = new URLSearchParams(searchParams)
const { hotel: hotelId } = getQueryParamsForEnterDetails(search)
if (!hotelId) {
return <SidePeek hotel={null} />
}
const hotel = await getHotelData({
hotelId: hotelId,
language: params.lang,
})
return <SidePeek hotel={hotel} />
export default function HotelSidePeekSlot() {
return null
}

View File

@@ -12,7 +12,7 @@ export default function HotelReservationLayout({
}: React.PropsWithChildren<LayoutArgs<LangParams>> & {
sidePeek: React.ReactNode
}) {
if (env.HIDE_FOR_NEXT_RELEASE) {
if (!env.ENABLE_BOOKING_FLOW) {
return notFound()
}
return (

View File

@@ -0,0 +1,8 @@
.page {
background-color: var(--Base-Background-Primary-Normal);
min-height: 50dvh;
max-width: var(--max-width);
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -1,8 +1,21 @@
import { env } from "@/env/server"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
import type { LangParams, PageArgs } from "@/types/params"
export default function HotelReservationPage({ params }: PageArgs<LangParams>) {
setLang(params.lang)
return null
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) {
return null
}
return (
<div className={styles.page}>
New booking flow! Please report errors/issues in Slack.
</div>
)
}

View File

@@ -29,10 +29,6 @@ export default async function SelectHotelMapPage({
params,
searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
setLang(params.lang)
const locations = await getLocations()
@@ -93,7 +89,10 @@ export default async function SelectHotelMapPage({
const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
const cityCoordinates = await getCityCoordinates({ city: city.name })
const cityCoordinates = await getCityCoordinates({
city: city.name,
hotel: { address: hotels[0].hotelData.address.streetAddress },
})
return (
<MapModal>

View File

@@ -1,7 +1,3 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
import { LangParams, LayoutArgs } from "@/types/params"
@@ -12,9 +8,6 @@ export default function HotelReservationLayout({
}: React.PropsWithChildren<
LayoutArgs<LangParams> & { modal: React.ReactNode }
>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return (
<div className={styles.layout}>
{children}

View File

@@ -72,7 +72,7 @@
.header nav {
display: block;
max-width: var(--max-width-navigation);
padding-left: 0;
padding: 0;
}
.sorter {

View File

@@ -59,8 +59,12 @@ export default async function SelectHotelPage({
(location) =>
location.name.toLowerCase() === searchParams.city.toLowerCase()
)
if (!city) return notFound()
const isCityWithCountry = (city: any): city is { country: string } =>
"country" in city
const intl = await getIntl()
const selectHotelParams = new URLSearchParams(searchParams)
const selectHotelParamsObject =
@@ -159,6 +163,7 @@ export default async function SelectHotelPage({
<div className={styles.mapContainer}>
<StaticMap
city={searchParams.city}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={180}
zoomLevel={11}

View File

@@ -105,7 +105,7 @@ export default async function SelectRatePage({
fromDate={fromDate.toDate()}
toDate={toDate.toDate()}
adultCount={adults}
childArray={children ?? []}
childArray={children}
/>
<Suspense key={hotelId} fallback={<RoomsContainerSkeleton />}>
@@ -115,7 +115,7 @@ export default async function SelectRatePage({
fromDate={fromDate.toDate()}
toDate={toDate.toDate()}
adultCount={adults}
childArray={children ?? []}
childArray={children}
/>
</Suspense>
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingHotelHeader() {
return <LoadingSpinner />
}

View File

@@ -1,64 +0,0 @@
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x3) var(--Spacing-x2);
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
justify-content: center;
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--Spacing-x1);
}
.descriptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.address {
display: flex;
gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
.dividerContainer {
display: none;
}
@media (min-width: 768px) {
.header {
padding: var(--Spacing-x4) 0;
}
.wrapper {
flex-direction: row;
gap: var(--Spacing-x6);
margin: 0 auto;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.titleContainer > h1 {
white-space: nowrap;
}
.dividerContainer {
display: block;
}
.address {
gap: var(--Spacing-x3);
}
}

View File

@@ -1,66 +0,0 @@
import { redirect } from "next/navigation"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./page.module.css"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HotelHeader({
params,
searchParams,
}: PageArgs<LangParams, { hotel: string }>) {
const home = `/${params.lang}`
if (!searchParams.hotel) {
redirect(home)
}
const hotelData = await getHotelData({
hotelId: searchParams.hotel,
language: params.lang,
})
if (!hotelData?.data) {
redirect(home)
}
const intl = await getIntl()
const hotel = hotelData.data.attributes
return (
<header className={styles.header}>
<div className={styles.wrapper}>
<div className={styles.titleContainer}>
<Title as="h3" level="h1">
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="textMediumContrast">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Caption color="textMediumContrast">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{ number: hotel.location.distanceToCentre }
)}
</Caption>
</address>
</div>
<div className={styles.dividerContainer}>
<Divider variant="vertical" color="subtle" />
</div>
<div className={styles.descriptionContainer}>
<Body color="baseTextHighContrast">
{hotel.hotelContent.texts.descriptions.short}
</Body>
</div>
</div>
</header>
)
}

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingSummaryHeader() {
return <LoadingSpinner />
}

View File

@@ -1,68 +0,0 @@
.mobileSummary {
display: block;
}
.desktopSummary {
display: none;
}
.summary {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
.hider {
display: none;
}
.shadow {
display: none;
}
@media screen and (min-width: 1367px) {
.mobileSummary {
display: none;
}
.desktopSummary {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
z-index: 10;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
margin-top: calc(0px - var(--Spacing-x9));
}
.shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-bottom: none;
}
.hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
top: calc(var(--booking-widget-desktop-height) - 6px);
margin-top: var(--Spacing-x4);
height: 40px;
}
}

View File

@@ -1,136 +0,0 @@
import { redirect } from "next/navigation"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
getPackages,
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import { SummaryBottomSheet } from "@/components/HotelReservation/EnterDetails/Summary/BottomSheet"
import {
generateChildrenString,
getQueryParamsForEnterDetails,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import styles from "./page.module.css"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs, SearchParams } from "@/types/params"
export default async function SummaryPage({
params,
searchParams,
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
const selectRoomParams = new URLSearchParams(searchParams)
const { hotel, rooms, fromDate, toDate } =
getQueryParamsForEnterDetails(selectRoomParams)
const {
adults,
children,
roomTypeCode,
rateCode,
packages: packageCodes,
} = rooms[0] // TODO: Handle multiple rooms
const availability = await getSelectedRoomAvailability({
hotelId: hotel,
adults,
children: children ? generateChildrenString(children) : undefined,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
packageCodes,
})
const user = await getProfileSafely()
const packages = packageCodes
? await getPackages({
hotelId: hotel,
startDate: fromDate,
endDate: toDate,
adults,
children: children?.length,
packageCodes,
})
: null
if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
redirect(selectRate(params.lang))
}
const prices = {
public: {
local: {
amount: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
amount: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
},
member: availability.memberRate
? {
local: {
amount: availability.memberRate.localPrice.pricePerStay,
currency: availability.memberRate.localPrice.currency,
},
euro: availability.memberRate.requestedPrice
? {
amount: availability.memberRate.requestedPrice.pricePerStay,
currency: availability.memberRate.requestedPrice.currency,
}
: undefined,
}
: undefined,
}
return (
<>
<div className={styles.mobileSummary}>
<SummaryBottomSheet>
<div className={styles.summary}>
<Summary
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
prices,
adults,
children,
rateDetails: availability.rateDetails,
cancellationText: availability.cancellationText,
packages,
}}
/>
</div>
</SummaryBottomSheet>
</div>
<div className={styles.desktopSummary}>
<div className={styles.hider} />
<div className={styles.summary}>
<Summary
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
prices,
adults,
children,
rateDetails: availability.rateDetails,
cancellationText: availability.cancellationText,
packages,
}}
/>
</div>
<div className={styles.shadow} />
</div>
</>
)
}

View File

@@ -1,9 +0,0 @@
import {
getCreditCardsSafely,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
export function preload() {
void getProfileSafely()
void getCreditCardsSafely()
}

View File

@@ -1,45 +0,0 @@
/**
* Due to css import issues with parallel routes we are forced to
* use a regular css file and import it in the page.tsx
* This is addressed in Next 15: https://github.com/vercel/next.js/pull/66300
*/
.enter-details-layout {
background-color: var(--Scandic-Brand-Warm-White);
}
.enter-details-layout__container {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x9);
/* simulates padding on viewport smaller than --max-width-navigation */
}
.enter-details-layout__content {
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.enter-details-layout__summaryContainer {
position: sticky;
bottom: 0;
left: 0;
right: 0;
}
@media screen and (min-width: 1367px) {
.enter-details-layout__container {
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.enter-details-layout__summaryContainer {
position: static;
display: grid;
grid-column: 2/3;
grid-row: 1/-1;
}
}

View File

@@ -1,10 +1,10 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo } from "react"
import { Lang } from "@/constants/languages"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import useTrackingStore from "@/stores/tracking"
import { createSDKPageObject } from "@/utils/tracking"
@@ -24,7 +24,7 @@ type Props = {
export default function EnterDetailsTracking(props: Props) {
const { initialHotelsTrackingData, userTrackingData, lang } = props
const currentStep = useStepsStore((state) => state.currentStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const { getPageLoadTime, hasRun } = useTrackingStore()
const pathName = usePathname()

View File

@@ -1,39 +0,0 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { setLang } from "@/i18n/serverContext"
import DetailsProvider from "@/providers/DetailsProvider"
import { preload } from "./_preload"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function StepLayout({
children,
hotelHeader,
params,
summary,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & {
hotelHeader: React.ReactNode
summary: React.ReactNode
}
>) {
setLang(params.lang)
preload()
const user = await getProfileSafely()
return (
<DetailsProvider isMember={!!user}>
<main className="enter-details-layout__layout">
{hotelHeader}
<div className={"enter-details-layout__container"}>
<div className={"enter-details-layout__content"}>{children}</div>
<aside className={"enter-details-layout__summaryContainer"}>
{summary}
</aside>
</div>
</main>
</DetailsProvider>
)
}

View File

@@ -0,0 +1,35 @@
.container {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x9);
}
.content {
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.summary {
position: sticky;
bottom: 0;
left: 0;
right: 0;
}
@media screen and (min-width: 1367px) {
.container {
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.summary {
position: static;
display: grid;
grid-column: 2/3;
grid-row: 1/-1;
}
}

View File

@@ -1,12 +1,11 @@
import "./enterDetailsLayout.css"
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
getBreakfastPackages,
getCreditCardsSafely,
getHotelData,
getPackages,
getProfileSafely,
getSelectedRoomAvailability,
getUserTracking,
@@ -15,20 +14,25 @@ import {
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import {
generateChildrenString,
getQueryParamsForEnterDetails,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import StepsProvider from "@/providers/StepsProvider"
import { setLang } from "@/i18n/serverContext"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import EnterDetailsTracking from "./enterDetailsTracking"
import styles from "./page.module.css"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
TrackingChannelEnum,
@@ -46,61 +50,77 @@ export default async function StepPage({
params: { lang },
searchParams,
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
if (!isValidStep(searchParams.step)) {
return notFound()
}
setLang(lang)
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
// Deleting step to avoid double searchparams after rewrite
selectRoomParams.delete("step")
const searchParamsString = selectRoomParams.toString()
const booking = getQueryParamsForEnterDetails(selectRoomParams)
const {
hotel: hotelId,
rooms,
rooms: [
{ adults, children, roomTypeCode, rateCode, packages: packageCodes },
], // TODO: Handle multiple rooms
fromDate,
toDate,
} = getQueryParamsForEnterDetails(selectRoomParams)
const {
adults,
children,
roomTypeCode,
rateCode,
packages: packageCodes,
} = rooms[0] // TODO: Handle multiple rooms
} = booking
const childrenAsString = children && generateChildrenString(children)
const breakfastInput = { adults, fromDate, hotelId, toDate }
void getBreakfastPackages(breakfastInput)
void getSelectedRoomAvailability({
hotelId,
const selectedRoomAvailabilityInput = {
adults,
children: childrenAsString,
hotelId,
packageCodes,
rateCode,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
packageCodes,
})
}
const roomAvailability = await getSelectedRoomAvailability({
hotelId,
adults,
children: childrenAsString,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
packageCodes,
})
void getProfileSafely()
void getBreakfastPackages(breakfastInput)
void getSelectedRoomAvailability(selectedRoomAvailabilityInput)
if (packageCodes?.length) {
void getPackages({
adults,
children: children?.length,
endDate: toDate,
hotelId,
packageCodes,
startDate: fromDate,
})
}
const packages = packageCodes
? await getPackages({
adults,
children: children?.length,
endDate: toDate,
hotelId,
packageCodes,
startDate: fromDate,
})
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput
)
const hotelData = await getHotelData({
hotelId,
language: lang,
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
language: lang,
})
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const user = await getProfileSafely()
const savedCreditCards = await getCreditCardsSafely()
const userTrackingData = await getUserTracking()
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
if (!hotelData || !roomAvailability) {
return notFound()
}
@@ -162,67 +182,98 @@ export default async function StepPage({
}
return (
<StepsProvider
<EnterDetailsProvider
bedTypes={roomAvailability.bedTypes}
booking={booking}
breakfastPackages={breakfastPackages}
isMember={!!user}
searchParams={searchParamsString}
packages={packages}
roomRate={{
memberRate: roomAvailability.memberRate,
publicRate: roomAvailability.publicRate,
}}
searchParamsStr={selectRoomParams.toString()}
step={searchParams.step}
user={user}
>
<section>
<HistoryStateManager />
<SelectedRoom
hotelId={hotelId}
room={roomAvailability.selectedRoom}
rateDescription={roomAvailability.cancellationText}
/>
<main>
<HotelHeader hotelData={hotelData} />
<div className={styles.container}>
<div className={styles.content}>
<section>
<HistoryStateManager />
<SelectedRoom
hotelId={hotelId}
room={roomAvailability.selectedRoom}
rateDescription={roomAvailability.cancellationText}
/>
{/* TODO: How to handle no beds found? */}
{roomAvailability.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
step={StepEnum.selectBed}
label={intl.formatMessage({ id: "Request bedtype" })}
>
<BedType bedTypes={roomAvailability.bedTypes} />
</SectionAccordion>
) : null}
{/* TODO: How to handle no beds found? */}
{roomAvailability.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
>
<BedType bedTypes={roomAvailability.bedTypes} />
</SectionAccordion>
) : null}
{breakfastPackages?.length ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
step={StepEnum.breakfast}
label={intl.formatMessage({ id: "Select breakfast options" })}
>
<Breakfast packages={breakfastPackages} />
</SectionAccordion>
) : null}
{breakfastPackages?.length ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({ id: "Select breakfast options" })}
step={StepEnum.breakfast}
>
<Breakfast packages={breakfastPackages} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
>
<Details user={user} memberPrice={memberPrice} />
</SectionAccordion>
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
>
<Details user={user} memberPrice={memberPrice} />
</SectionAccordion>
<SectionAccordion
header={mustBeGuaranteed ? paymentGuarantee : payment}
step={StepEnum.payment}
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions
}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
</SectionAccordion>
</section>
<SectionAccordion
header={mustBeGuaranteed ? paymentGuarantee : payment}
step={StepEnum.payment}
label={
mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod
}
>
<Suspense>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions
}
supportedCards={
hotelData.data.attributes.merchantInformationData.cards
}
mustBeGuaranteed={mustBeGuaranteed}
/>
</Suspense>
</SectionAccordion>
</section>
</div>
<aside className={styles.summary}>
<Summary
adults={adults}
fromDate={fromDate}
hotelId={hotelId}
kids={children}
packageCodes={packageCodes}
rateCode={rateCode}
roomTypeCode={roomTypeCode}
toDate={toDate}
/>
</aside>
</div>
</main>
<EnterDetailsTracking
initialHotelsTrackingData={initialHotelsTrackingData}
userTrackingData={userTrackingData}
@@ -232,6 +283,6 @@ export default async function StepPage({
pageData={initialPageTrackingData}
hotelInfo={initialHotelsTrackingData}
/>
</StepsProvider>
</EnterDetailsProvider>
)
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1,17 @@
import { env } from "@/env/server"
import BookingWidget, { preload } from "@/components/BookingWidget"
import { PageArgs } from "@/types/params"
export default async function BookingWidgetPage({
searchParams,
}: PageArgs<{}, URLSearchParams>) {
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) {
return null
}
preload()
return <BookingWidget searchParams={searchParams} />
}

View File

@@ -3,7 +3,7 @@ import { env } from "@/env/server"
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
export default function LoadingBookingWidget() {
if (env.HIDE_FOR_NEXT_RELEASE) {
if (!env.ENABLE_BOOKING_FLOW) {
return null
}

View File

@@ -8,7 +8,7 @@ import { PageArgs } from "@/types/params"
export default async function BookingWidgetPage({
searchParams,
}: PageArgs<{}, URLSearchParams>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
if (!env.ENABLE_BOOKING_WIDGET) {
return null
}

View File

@@ -8,7 +8,7 @@ import { setLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
if (!env.SHOW_SITE_WIDE_ALERT) {
return null
}

View File

@@ -63,9 +63,9 @@ export default async function RootLayout({
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking />
{!env.HIDE_FOR_NEXT_RELEASE && <>{sitewidealert}</>}
{sitewidealert}
{header}
{!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}</>}
{bookingwidget}
{children}
{footer}
<ToastHandler />

View File

@@ -29,6 +29,7 @@ const validateJsonBody = z.object({
})
.optional(),
locale: z.nativeEnum(Lang),
publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(),
uid: z.string(),
url: z.string().optional(),
page_settings: z
@@ -67,15 +68,19 @@ export async function POST(request: NextRequest) {
data: { content_type, entry },
},
} = validatedData
const refsTag = generateRefsResponseTag(entry.locale, entry.uid)
const refTag = generateRefTag(entry.locale, content_type.uid, entry.uid)
const tag = generateTag(entry.locale, entry.uid)
// The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not
const entryLocale = entry.publish_details?.locale ?? entry.locale
const refsTag = generateRefsResponseTag(entryLocale, entry.uid)
const refTag = generateRefTag(entryLocale, content_type.uid, entry.uid)
const tag = generateTag(entryLocale, entry.uid)
const languageSwitcherTag = generateTag(
entry.locale,
entryLocale,
entry.uid,
languageSwitcherAffix
)
const metadataTag = generateTag(entry.locale, entry.uid, metadataAffix)
const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix)
console.info(`Revalidating refsTag: ${refsTag}`)
revalidateTag(refsTag)
@@ -94,12 +99,12 @@ export async function POST(request: NextRequest) {
if (entry.breadcrumbs) {
const breadcrumbsRefsTag = generateRefsResponseTag(
entry.locale,
entryLocale,
entry.uid,
breadcrumbsAffix
)
const breadcrumbsTag = generateTag(
entry.locale,
entryLocale,
entry.uid,
breadcrumbsAffix
)
@@ -113,7 +118,7 @@ export async function POST(request: NextRequest) {
if (entry.page_settings?.hide_booking_widget) {
const bookingwidgetTag = generateTag(
entry.locale,
entryLocale,
entry.uid,
bookingwidgetAffix
)

View File

@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h4" level="h3" color="red" className={styles.title}>
<Title
as="h4"
level="h3"
color="red"
className={styles.title}
textAlign="center"
>
{intl.formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{intl.formatMessage({ id: "Where should you go next?" })}

View File

@@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() {
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h4" level="h3" color="red" className={styles.title}>
<Title
as="h4"
level="h3"
color="red"
className={styles.title}
textAlign="center"
>
{intl.formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{intl.formatMessage({ id: "Where should you go next?" })}

View File

@@ -8,6 +8,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import getSingleDecimal from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
@@ -47,7 +48,7 @@ export default async function HotelListingItem({
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{ number: distanceToCentre }
{ number: getSingleDecimal(distanceToCentre / 1000) }
)}
</Caption>
</div>

View File

@@ -8,6 +8,7 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import getSingleDecimal from "@/utils/numberFormatting"
import styles from "./introSection.module.css"
@@ -25,7 +26,7 @@ export default async function IntroSection({
const { distanceToCentre } = location
const formattedDistanceText = intl.formatMessage(
{ id: "Distance in km to city centre" },
{ number: distanceToCentre }
{ number: getSingleDecimal(distanceToCentre / 1000) }
)
const lang = getLang()
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`

View File

@@ -1,24 +1,23 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import { ChevronRightSmallIcon } from "@/components/Icons"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getRoomNameAsParam } from "../../utils"
import styles from "./roomCard.module.css"
import type { RoomCardProps } from "@/types/components/hotelPage/room"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
export function RoomCard({ hotelId, room }: RoomCardProps) {
export function RoomCard({ room }: RoomCardProps) {
const { images, name, roomSize, occupancy } = room
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
const size =
roomSize?.min === roomSize?.max
@@ -51,21 +50,11 @@ export function RoomCard({ hotelId, room }: RoomCardProps) {
)}
</Body>
</div>
<Button
intent="text"
type="button"
size="medium"
theme="base"
onClick={() =>
openSidePeek({
key: SidePeekEnum.roomDetails,
hotelId,
roomTypeCode: room.roomTypes[0].code,
})
}
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
<Button intent="text" type="button" size="medium" theme="base" asChild>
<Link scroll={false} href={`?s=${getRoomNameAsParam(name)}`}>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon color="burgundy" width={20} height={20} />
</Link>
</Button>
</div>
</article>

View File

@@ -15,7 +15,7 @@ import styles from "./rooms.module.css"
import type { RoomsProps } from "@/types/components/hotelPage/room"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
export function Rooms({ hotelId, rooms }: RoomsProps) {
export function Rooms({ rooms }: RoomsProps) {
const intl = useIntl()
const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
@@ -45,7 +45,7 @@ export function Rooms({ hotelId, rooms }: RoomsProps) {
>
{rooms.map((room) => (
<div key={room.id}>
<RoomCard hotelId={hotelId} room={room} />
<RoomCard room={room} />
</div>
))}
</Grids.Stackable>

View File

@@ -0,0 +1,118 @@
import Link from "next/link"
import ImageGallery from "@/components/ImageGallery"
import { getBedIcon } from "@/components/SidePeeks/RoomSidePeek/bedIcon"
import { getFacilityIcon } from "@/components/SidePeeks/RoomSidePeek/facilityIcon"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getRoomNameAsParam } from "../../utils"
import styles from "./room.module.css"
import type { RoomSidePeekProps } from "@/types/components/hotelPage/sidepeek/room"
export default async function RoomSidePeek({ room }: RoomSidePeekProps) {
const intl = await getIntl()
const { roomSize, occupancy, descriptions, images } = room
const roomDescription = descriptions.medium
const totalOccupancy = occupancy.total
// TODO: Not defined where this should lead.
const ctaUrl = ""
return (
<SidePeek contentKey={getRoomNameAsParam(room.name)} title={room.name}>
<div className={styles.content}>
<div className={styles.innerContent}>
<Body color="baseTextMediumContrast">
{roomSize.min === roomSize.max
? roomSize.min
: `${roomSize.min} - ${roomSize.max}`}
m².{" "}
{intl.formatMessage(
{ id: "booking.accommodatesUpTo" },
{ nrOfGuests: totalOccupancy }
)}
</Body>
<div className={styles.imageContainer}>
<ImageGallery images={images} title={room.name} height={280} />
</div>
<Body color="uiTextHighContrast">{roomDescription}</Body>
</div>
<div className={styles.innerContent}>
<Subtitle type="two" color="uiTextHighContrast" asChild>
<h3>
{intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })}
</h3>
</Subtitle>
<ul className={styles.facilityList}>
{room.roomFacilities
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => {
const Icon = getFacilityIcon(facility.icon)
return (
<li className={styles.listItem} key={facility.name}>
{Icon && (
<Icon
width={24}
height={24}
color="uiTextMediumContrast"
/>
)}
<Body
asChild
className={!Icon ? styles.noIcon : undefined}
color="uiTextMediumContrast"
>
<span>{facility.name}</span>
</Body>
</li>
)
})}
</ul>
</div>
<div className={styles.innerContent}>
<Subtitle type="two" color="uiTextHighContrast" asChild>
<h3>{intl.formatMessage({ id: "booking.bedOptions" })}</h3>
</Subtitle>
<Body color="grey">
{intl.formatMessage({ id: "booking.basedOnAvailability" })}
</Body>
<ul className={styles.bedOptions}>
{room.roomTypes.map((roomType) => {
const BedIcon = getBedIcon(roomType.mainBed.type)
return (
<li className={styles.listItem} key={roomType.code}>
{BedIcon && (
<BedIcon
color="uiTextMediumContrast"
width={24}
height={24}
/>
)}
<Body color="uiTextMediumContrast" asChild>
<span>{roomType.mainBed.description}</span>
</Body>
</li>
)
})}
</ul>
</div>
</div>
{ctaUrl && (
<div className={styles.buttonContainer}>
<Button fullWidth theme="base" intent="primary" asChild>
<Link href={ctaUrl}>
{intl.formatMessage({ id: "booking.selectRoom" })}
</Link>
</Button>
</div>
)}
</SidePeek>
)
}

View File

@@ -0,0 +1,48 @@
.content {
display: grid;
gap: var(--Spacing-x2);
position: relative;
margin-bottom: calc(
var(--Spacing-x4) * 2 + 80px
); /* Creates space between the wrapper and buttonContainer */
}
.innerContent {
display: grid;
gap: var(--Spacing-x-one-and-half);
}
.imageContainer {
position: relative;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
.facilityList {
column-count: 2;
column-gap: var(--Spacing-x2);
}
.bedOptions {
display: flex;
flex-direction: column;
}
.listItem {
display: flex;
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.noIcon {
margin-left: var(--Spacing-x4);
}
.buttonContainer {
background-color: var(--Base-Background-Primary-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x4) var(--Spacing-x2);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
}

View File

@@ -1,2 +1,3 @@
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
export { default as RoomSidePeek } from "./Room"
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"

View File

@@ -1,15 +1,22 @@
import hotelPageParams from "@/constants/routes/hotelPageParams"
import { notFound } from "next/navigation"
import {
activities,
amenities,
meetingsAndConferences,
restaurantAndBar,
} from "@/constants/routes/hotelPageParams"
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import { getHotelData, getHotelPage } from "@/lib/trpc/memoizedRequests"
import AccordionSection from "@/components/Blocks/Accordion"
import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek"
import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider"
import Alert from "@/components/TempDesignSystem/Alert"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getRestaurantHeading } from "@/utils/facilityCards"
import { generateHotelSchema } from "@/utils/jsonSchemas"
import DynamicMap from "./Map/DynamicMap"
import MapCard from "./Map/MapCard"
@@ -21,60 +28,101 @@ import Facilities from "./Facilities"
import IntroSection from "./IntroSection"
import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms"
import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks"
import {
AboutTheHotelSidePeek,
RoomSidePeek,
WellnessAndExerciseSidePeek,
} from "./SidePeeks"
import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css"
import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities"
import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import type { Facility } from "@/types/hotel"
export default async function HotelPage() {
const intl = await getIntl()
export default async function HotelPage({ hotelId }: HotelPageProps) {
const lang = getLang()
const [intl, hotelPageData, hotelData] = await Promise.all([
getIntl(),
getHotelPage(),
getHotelData({ hotelId, language: lang }),
])
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const hotelData = await serverClient().hotel.get()
if (!hotelData) {
return null
if (!hotelData?.data || !hotelPageData) {
return notFound()
}
const jsonSchema = generateHotelSchema(hotelData.data.attributes)
const { faq, content } = hotelPageData
const {
hotelId,
hotelName,
hotelDescriptions,
hotelLocation,
hotelAddress,
hotelRatings,
hotelDetailedFacilities,
hotelImages,
roomCategories,
activitiesCard,
name,
address,
pointsOfInterest,
facilities,
faq,
alerts,
gallery,
specialAlerts,
healthAndWellness,
restaurantImages,
conferencesAndMeetings,
hotelContent,
detailedFacilities,
healthFacilities,
contact,
socials,
ecoLabels,
} = hotelData
contactInformation,
socialMedia,
hotelFacts,
location,
ratings,
} = hotelData.data.attributes
const roomCategories =
hotelData.included?.filter((item) => item.type === "roomcategories") || []
const images = gallery?.smallerImages
const description = hotelContent.texts.descriptions.short
const activitiesCard = content?.[0]?.upcoming_activities_card || null
const facilities: Facility[] = [
{
...restaurantImages,
id: FacilityCardTypeEnum.restaurant,
headingText: restaurantImages?.headingText ?? "",
heroImages: restaurantImages?.heroImages ?? [],
},
{
...conferencesAndMeetings,
id: FacilityCardTypeEnum.conference,
headingText: conferencesAndMeetings?.headingText ?? "",
heroImages: conferencesAndMeetings?.heroImages ?? [],
},
{
...healthAndWellness,
id: FacilityCardTypeEnum.wellness,
headingText: healthAndWellness?.headingText ?? "",
heroImages: healthAndWellness?.heroImages ?? [],
},
]
const topThreePois = pointsOfInterest.slice(0, 3)
const coordinates = {
lat: hotelLocation.latitude,
lng: hotelLocation.longitude,
lat: location.latitude,
lng: location.longitude,
}
return (
<div className={styles.pageContainer}>
<script
type={jsonSchema.type}
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonSchema.jsonLd),
}}
/>
<div className={styles.hotelImages}>
{hotelImages?.length && (
<PreviewImages images={hotelImages} hotelName={hotelName} />
)}
{images?.length && <PreviewImages images={images} hotelName={name} />}
</div>
<TabNavigation
restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)}
restaurantTitle={getRestaurantHeading(detailedFacilities)}
hasActivities={!!activitiesCard}
hasFAQ={!!faq.accordions.length}
/>
@@ -82,18 +130,18 @@ export default async function HotelPage() {
<div id={HotelHashValues.overview} className={styles.overview}>
<div className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescriptions.descriptions.short}
location={hotelLocation}
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
hotelName={name}
hotelDescription={description}
location={location}
address={address}
tripAdvisor={ratings?.tripAdvisor}
/>
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
<AmenitiesList detailedFacilities={detailedFacilities} />
</div>
{alerts.length ? (
{specialAlerts.length ? (
<div className={styles.alertsContainer}>
{alerts.map((alert) => (
{specialAlerts.map((alert) => (
<Alert
key={alert.id}
type={alert.type}
@@ -104,7 +152,7 @@ export default async function HotelPage() {
</div>
) : null}
</div>
<Rooms hotelId={hotelId} rooms={roomCategories} />
<Rooms rooms={roomCategories} />
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
{faq.accordions.length > 0 && (
<AccordionSection accordion={faq.accordions} title={faq.title} />
@@ -114,14 +162,14 @@ export default async function HotelPage() {
<>
<aside className={styles.mapContainer}>
<MapWithCardWrapper>
<StaticMap coordinates={coordinates} hotelName={hotelName} />
<MapCard hotelName={hotelName} pois={topThreePois} />
<StaticMap coordinates={coordinates} hotelName={name} />
<MapCard hotelName={name} pois={topThreePois} />
</MapWithCardWrapper>
</aside>
<MobileMapToggle />
<DynamicMap
apiKey={googleMapsApiKey}
hotelName={hotelName}
hotelName={name}
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
mapId={googleMapId}
@@ -129,24 +177,23 @@ export default async function HotelPage() {
</>
) : null}
<SidePeekProvider>
{/* eslint-disable import/no-named-as-default-member */}
<SidePeek
contentKey={hotelPageParams.amenities[lang]}
contentKey={amenities[lang]}
title={intl.formatMessage({ id: "Amenities" })}
>
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<AboutTheHotelSidePeek
hotelAddress={hotelAddress}
coordinates={hotelLocation}
contact={contact}
socials={socials}
ecoLabels={ecoLabels}
descriptions={hotelDescriptions}
hotelAddress={address}
coordinates={location}
contact={contactInformation}
socials={socialMedia}
ecoLabels={hotelFacts.ecoLabels}
descriptions={hotelContent.texts}
/>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
contentKey={restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}
>
{/* TODO */}
@@ -157,22 +204,23 @@ export default async function HotelPage() {
buttonUrl="#"
/>
<SidePeek
contentKey={hotelPageParams.activities[lang]}
contentKey={activities[lang]}
title={intl.formatMessage({ id: "Activities" })}
>
{/* TODO */}
Activities
</SidePeek>
<SidePeek
contentKey={hotelPageParams.meetingsAndConferences[lang]}
contentKey={meetingsAndConferences[lang]}
title={intl.formatMessage({ id: "Meetings & Conferences" })}
>
{/* TODO */}
Meetings & Conferences
</SidePeek>
{/* eslint-enable import/no-named-as-default-member */}
{roomCategories.map((room) => (
<RoomSidePeek key={room.name} room={room} />
))}
</SidePeekProvider>
<HotelReservationSidePeek hotel={null} />
</div>
)
}

View File

@@ -0,0 +1,3 @@
export function getRoomNameAsParam(roomName: string) {
return roomName.replace(/[()]/g, "").replaceAll(" ", "-").toLowerCase()
}

View File

@@ -7,7 +7,7 @@
position: fixed;
top: 0;
width: 100%;
z-index: 99999;
z-index: var(--header-z-index);
height: var(--current-mobile-site-header-height);
max-width: var(--max-width-navigation);
margin: 0 auto;

View File

@@ -33,9 +33,18 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const [isSelectingFrom, setIsSelectingFrom] = useState(true)
function close() {
const close = useCallback(() => {
if (!selectedDate.toDate) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-DD"),
})
setIsSelectingFrom(true)
}
setIsOpen(false)
}
}, [name, setValue, selectedDate])
function showOnFocus() {
setIsOpen(true)
@@ -72,19 +81,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
if (!selectedDate.toDate) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate)
.add(1, "day")
.format("YYYY-MM-DD"),
})
setIsSelectingFrom(true)
}
setIsOpen(false)
close()
}
},
[setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref]
[close, ref]
)
function closeOnBlur(evt: FocusEvent) {

View File

@@ -40,6 +40,7 @@
content: "·";
margin-left: var(--Spacing-x1);
}
&:last-child {
&::after {
content: "";
@@ -56,12 +57,14 @@
.details {
padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4);
}
.bottomContainer {
border-top: 1px solid var(--Base-Text-Medium-contrast);
padding-top: var(--Spacing-x2);
flex-direction: row;
align-items: center;
}
.navigationContainer {
border-bottom: 0;
padding-bottom: 0;

View File

@@ -9,9 +9,11 @@
.mainNavigationItem {
padding: var(--Spacing-x3) 0;
border-bottom: 1px solid var(--Base-Border-Normal);
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: 0;
}

View File

@@ -66,11 +66,13 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
zipCode: "",
},
password: "",
termsAccepted: false,
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(signUpSchema),
reValidateMode: "onChange",
shouldFocusError: true,
})
async function onSubmit(data: SignUpSchema) {
@@ -145,7 +147,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
</header>
<NewPassword
name="password"
placeholder="Password"
label={intl.formatMessage({ id: "Password" })}
/>
</section>
@@ -157,17 +158,21 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
</header>
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
<Body>
{intl.formatMessage({
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
})}{" "}
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
</Link>
{intl.formatMessage<React.ReactNode>(
{ id: "signupPage.terms" },
{
termsLink: (str) => (
<Link
variant="underscored"
color="peach80"
target="_blank"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Body>
</Checkbox>
</section>
@@ -181,7 +186,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
{!methods.formState.isValid ? (
<Button
className={styles.signUpButton}
type="button"
type="submit"
theme="base"
intent="primary"
onClick={() => methods.trigger()}

View File

@@ -98,6 +98,7 @@ export default function ChildInfoSelector({
{...register(ageFieldName, {
required: true,
})}
isNestedInModal={true}
/>
</div>
<div>
@@ -114,6 +115,7 @@ export default function ChildInfoSelector({
{...register(bedFieldName, {
required: true,
})}
isNestedInModal={true}
/>
) : null}
</div>

View File

@@ -21,9 +21,11 @@ import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerDialog({
rooms,
onClose,
isOverflowed = false,
}: {
rooms: GuestsRoom[]
onClose: () => void
isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required
}) {
const intl = useIntl()
const doneLabel = intl.formatMessage({ id: "Done" })
@@ -124,7 +126,7 @@ export default function GuestsRoomsPickerDialog({
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="bottom"
position={isOverflowed ? "top" : "bottom"}
arrow="left"
>
{rooms.length < 4 ? (

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import {
Button,
Dialog,
@@ -21,16 +21,19 @@ import styles from "./guests-rooms-picker.module.css"
import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function GuestsRoomsPickerForm() {
const { watch } = useFormContext()
const { watch, trigger } = useFormContext()
const rooms = watch("rooms") as GuestsRoom[]
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [containerHeight, setContainerHeight] = useState(0)
const childCount = rooms[0] ? rooms[0].child.length : 0 // ToDo Update for multiroom later
const htmlElement =
typeof window !== "undefined" ? document.querySelector("body") : null
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
function setOverflowClip(isOpen: boolean) {
async function setOverflowClip(isOpen: boolean) {
if (htmlElement) {
if (isOpen) {
htmlElement.style.overflow = "visible"
@@ -40,24 +43,86 @@ export default function GuestsRoomsPickerForm() {
htmlElement.style.overflow = "clip !important"
}
}
if (!isOpen) {
const state = await trigger("rooms")
if (state) {
setIsOpen(isOpen)
}
}
}
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const updateHeight = useCallback(() => {
if (typeof window !== undefined) {
// Get available space for picker to show without going beyond screen
let maxHeight =
window.innerHeight -
(document.querySelector("#booking-widget")?.getBoundingClientRect()
.bottom ?? 0) -
50
const innerContainerHeight = document
.querySelector(".guests_picker_popover")
?.getBoundingClientRect().height
if (
maxHeight != containerHeight &&
innerContainerHeight &&
maxHeight <= innerContainerHeight
) {
setContainerHeight(maxHeight)
} else if (
containerHeight &&
innerContainerHeight &&
maxHeight > innerContainerHeight
) {
setContainerHeight(0)
}
}
}, [containerHeight])
useEffect(() => {
if (typeof window !== undefined && isDesktop) {
updateHeight()
}
}, [childCount, isDesktop, updateHeight])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip}>
<Trigger rooms={rooms} className={styles.triggerDesktop} />
<Popover placement="bottom start" offset={36}>
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerDesktop}
triggerFn={() => {
setIsOpen(true)
}}
/>
<Popover
className="guests_picker_popover"
placement="bottom start"
offset={36}
style={containerHeight ? { overflow: "auto" } : {}}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
{({ close }) => (
<PickerForm
rooms={rooms}
onClose={close}
isOverflowed={!!containerHeight}
/>
)}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger>
<Trigger rooms={rooms} className={styles.triggerMobile} />
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerMobile}
triggerFn={() => {
setIsOpen(true)
}}
/>
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
@@ -70,14 +135,20 @@ export default function GuestsRoomsPickerForm() {
function Trigger({
rooms,
className,
triggerFn,
}: {
rooms: GuestsRoom[]
className: string
triggerFn?: () => void
}) {
const intl = useIntl()
return (
<Button className={`${className} ${styles.btn}`} type="button">
<Button
className={`${className} ${styles.btn}`}
type="button"
onPress={triggerFn}
>
<Body>
{rooms.map((room, i) => (
<span key={i}>

View File

@@ -1,34 +0,0 @@
.actions {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
padding: var(--Spacing-x1) var(--Spacing-x2);
}
@media screen and (max-width: 767px) {
.actions {
& > button[class*="btn"][class*="icon"][class*="small"] {
border-bottom: 1px solid var(--Base-Border-Subtle);
border-radius: 0;
justify-content: space-between;
&:last-of-type {
border-bottom: none;
}
& > svg {
order: 2;
}
}
}
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x1);
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
justify-content: center;
padding: var(--Spacing-x1) var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,15 @@
.actions {
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x3);
grid-auto-columns: auto;
grid-auto-flow: column;
grid-template-columns: auto;
justify-content: flex-start;
}
}

View File

@@ -1,11 +1,5 @@
import {
CalendarIcon,
ContractIcon,
DownloadIcon,
PrinterIcon,
} from "@/components/Icons"
import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import styles from "./actions.module.css"
@@ -15,20 +9,13 @@ export default async function Actions() {
return (
<div className={styles.actions}>
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<CalendarIcon />
<CalendarAddIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<ContractIcon />
{intl.formatMessage({ id: "View terms" })}
<EditIcon />
{intl.formatMessage({ id: "Manage booking" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<PrinterIcon />
{intl.formatMessage({ id: "Print confirmation" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}

View File

@@ -1,12 +1,12 @@
.header,
.hgroup {
align-items: center;
display: flex;
flex-direction: column;
}
.header {
gap: var(--Spacing-x3);
gap: var(--Spacing-x2);
grid-area: header;
}
.hgroup {
@@ -14,5 +14,5 @@
}
.body {
max-width: 560px;
max-width: 720px;
}

View File

@@ -1,11 +1,12 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import Actions from "./Actions"
import styles from "./header.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
@@ -30,31 +31,15 @@ export default async function Header({
return (
<header className={styles.header}>
<hgroup className={styles.hgroup}>
<BiroScript color="red" tilted="small" type="two">
{intl.formatMessage({ id: "See you soon!" })}
</BiroScript>
<Title
as="h4"
color="red"
textAlign="center"
textTransform="regular"
type="h2"
>
<Title as="h2" color="red" textTransform="uppercase" type="h2">
{intl.formatMessage({ id: "booking.confirmation.title" })}
</Title>
<Title
as="h4"
color="burgundy"
textAlign="center"
textTransform="regular"
type="h1"
>
<Title as="h2" color="burgundy" textTransform="uppercase" type="h1">
{hotel.name}
</Title>
</hgroup>
<Body className={styles.body} textAlign="center">
{text}
</Body>
<Body className={styles.body}>{text}</Body>
<Actions />
</header>
)
}

View File

@@ -0,0 +1,5 @@
import styles from "./room.module.css"
export default function Room() {
return <article className={styles.room}></article>
}

View File

@@ -0,0 +1,5 @@
import styles from "./rooms.module.css"
export default function Rooms() {
return <section className={styles.rooms}></section>
}

View File

@@ -0,0 +1,6 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
grid-area: booking;
}

View File

@@ -1,154 +1,5 @@
import { profile } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import {
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./summary.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function Summary({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const user = await getProfileSafely()
const { firstName, lastName } = booking.guest
const membershipNumber = user?.membership?.membershipNumber
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
dt(booking.checkInDate.setHours(0, 0, 0)),
"days"
)
const breakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
return (
<div className={styles.summary}>
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Guest" })}
</Body>
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
{membershipNumber ? (
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "membership.no" },
{ membershipNumber }
)}
</Body>
) : null}
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
</div>
{user ? (
<Link className={styles.link} href={profile[lang]} variant="icon">
<PersonIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Go to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Payment" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "guest.paid" },
{
amount: intl.formatNumber(booking.totalPrice),
currency: booking.currencyCode,
}
)}
</Body>
<Body color="uiTextHighContrast">Date information N/A</Body>
<Body color="uiTextHighContrast">Card information N/A</Body>
</div>
{/* # href until more info */}
{user ? (
<Link className={styles.link} href="#" variant="icon">
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Save card to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Booking" })}
</Body>
<Body color="uiTextHighContrast">
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
,{" "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: booking.adults }
)}
</Body>
{breakfastPackage ? (
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast added" })}
</Body>
) : null}
<Body color="uiTextHighContrast">Bedtype N/A</Body>
</div>
{/* # href until more info */}
<Link className={styles.link} href="#" variant="icon">
<EditIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Manage booking" })}
</Caption>
</Link>
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Hotel" })}
</Body>
<Body color="uiTextHighContrast">{hotel.name}</Body>
<Body color="uiTextHighContrast">
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</Body>
<Body color="uiTextHighContrast">
{hotel.contactInformation.phoneNumber}
</Body>
<Caption color="uiTextMediumContrast" className={styles.latLong}>
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
</Caption>
</div>
<div className={styles.hotelLinks}>
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
{hotel.contactInformation.websiteUrl}
</Link>
<Link
color="peach80"
href={`mailto:${hotel.contactInformation.email}`}
>
{hotel.contactInformation.email}
</Link>
</div>
</div>
</div>
)
export default function Summary() {
return <aside className={styles.summary}>SUMMARY</aside>
}

View File

@@ -1,31 +1,4 @@
.summary {
display: grid;
gap: var(--Spacing-x3);
}
.container,
.textContainer {
display: flex;
flex-direction: column;
}
.container {
gap: var(--Spacing-x-one-and-half);
}
.textContainer {
gap: var(--Spacing-x-half);
}
.container .textContainer .latLong {
padding-top: var(--Spacing-x1);
}
.hotelLinks {
display: flex;
flex-direction: column;
}
.summary .container .link {
gap: var(--Spacing-x1);
background-color: hotpink;
grid-area: summary;
}

View File

@@ -0,0 +1,154 @@
import { profile } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import {
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./summary.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function Summary({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const user = await getProfileSafely()
const { firstName, lastName } = booking.guest
const membershipNumber = user?.membership?.membershipNumber
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
dt(booking.checkInDate.setHours(0, 0, 0)),
"days"
)
const breakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
return (
<div className={styles.summary}>
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Guest" })}
</Body>
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
{membershipNumber ? (
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "membership.no" },
{ membershipNumber }
)}
</Body>
) : null}
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
</div>
{user ? (
<Link className={styles.link} href={profile[lang]} variant="icon">
<PersonIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Go to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Payment" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "guest.paid" },
{
amount: intl.formatNumber(booking.totalPrice),
currency: booking.currencyCode,
}
)}
</Body>
<Body color="uiTextHighContrast">Date information N/A</Body>
<Body color="uiTextHighContrast">Card information N/A</Body>
</div>
{/* # href until more info */}
{user ? (
<Link className={styles.link} href="#" variant="icon">
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Save card to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Booking" })}
</Body>
<Body color="uiTextHighContrast">
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
,{" "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: booking.adults }
)}
</Body>
{breakfastPackage ? (
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast added" })}
</Body>
) : null}
<Body color="uiTextHighContrast">Bedtype N/A</Body>
</div>
{/* # href until more info */}
<Link className={styles.link} href="#" variant="icon">
<EditIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Manage booking" })}
</Caption>
</Link>
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Hotel" })}
</Body>
<Body color="uiTextHighContrast">{hotel.name}</Body>
<Body color="uiTextHighContrast">
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</Body>
<Body color="uiTextHighContrast">
{hotel.contactInformation.phoneNumber}
</Body>
<Caption color="uiTextMediumContrast" className={styles.latLong}>
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
</Caption>
</div>
<div className={styles.hotelLinks}>
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
{hotel.contactInformation.websiteUrl}
</Link>
<Link
color="peach80"
href={`mailto:${hotel.contactInformation.email}`}
>
{hotel.contactInformation.email}
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
.summary {
display: grid;
gap: var(--Spacing-x3);
}
.container,
.textContainer {
display: flex;
flex-direction: column;
}
.container {
gap: var(--Spacing-x-one-and-half);
}
.textContainer {
gap: var(--Spacing-x-half);
}
.container .textContainer .latLong {
padding-top: var(--Spacing-x1);
}
.hotelLinks {
display: flex;
flex-direction: column;
}
.summary .container .link {
gap: var(--Spacing-x1);
}

View File

@@ -1,23 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
}
.booking {
display: grid;
gap: var(--Spacing-x-one-and-half);
grid-template-areas:
"image"
"details"
"actions";
}
@media screen and (min-width: 768px) {
.booking {
grid-template-areas:
"details image"
"actions actions";
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
}
}

View File

@@ -1,31 +0,0 @@
import Actions from "./Actions"
import Details from "./Details"
import Header from "./Header"
import HotelImage from "./HotelImage"
import Summary from "./Summary"
import TotalPrice from "./TotalPrice"
import styles from "./bookingConfirmation.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default function BookingConfirmation({
confirmationNumber,
}: BookingConfirmationProps) {
return (
<>
<Header confirmationNumber={confirmationNumber} />
<section className={styles.section}>
<div className={styles.booking}>
<Details confirmationNumber={confirmationNumber} />
<HotelImage confirmationNumber={confirmationNumber} />
<Actions />
</div>
{/* Supposed Ancillaries */}
<Summary confirmationNumber={confirmationNumber} />
<TotalPrice confirmationNumber={confirmationNumber} />
{/* Supposed Info Card - Where should it come from?? */}
</section>
</>
)
}

View File

@@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -20,12 +19,19 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType({ bedTypes }: BedTypeProps) {
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
const completeStep = useStepsStore((state) => state.completeStep)
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
const initialBedType = useEnterDetailsStore(
(state) => state.formValues?.bedType?.roomTypeCode
)
const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
const methods = useForm<BedTypeFormSchema>({
defaultValues: bedType ? { bedType } : undefined,
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(bedTypeFormSchema),
@@ -43,10 +49,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
roomTypeCode: matchingRoom.value,
}
updateBedType(bedType)
completeStep()
}
},
[bedTypes, completeStep, updateBedType]
[bedTypes, updateBedType]
)
useEffect(() => {

View File

@@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) {
const intl = useIntl()
const breakfast = useDetailsStore(({ data }) =>
data.breakfast
? data.breakfast.code
: data.breakfast === false
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
formValues?.breakfast
? formValues.breakfast.code
: formValues?.breakfast === false
? "false"
: data.breakfast
: undefined
)
const updateBreakfast = useDetailsStore(
const breakfast = useEnterDetailsStore((state) =>
state.breakfast
? state.breakfast.code
: state.breakfast === false
? "false"
: undefined
)
const completeStep = useEnterDetailsStore(
(state) => state.actions.completeStep
)
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
)
const completeStep = useStepsStore((state) => state.completeStep)
const methods = useForm<BreakfastFormSchema>({
defaultValues: breakfast ? { breakfast } : undefined,
defaultValues: formValuesBreakfast
? { breakfast: formValuesBreakfast }
: undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(breakfastFormSchema),
@@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
} else {
updateBreakfast(false)
}
completeStep()
},
[completeStep, packages, updateBreakfast]
[packages, updateBreakfast]
)
useEffect(() => {

View File

@@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang"
import styles from "./joinScandicFriendsCard.module.css"
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
@@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({
position="enter details"
trackingId="join-scandic-friends-enter-details"
variant="breadcrumb"
target="_blank"
>
{intl.formatMessage({ id: "Log in" })}
</LoginButton>

View File

@@ -4,8 +4,7 @@ import { useCallback } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -27,45 +26,35 @@ import type {
const formID = "enter-details"
export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl()
const initialData = useDetailsStore((state) => ({
countryCode: state.data.countryCode,
email: state.data.email,
firstName: state.data.firstName,
lastName: state.data.lastName,
phoneNumber: state.data.phoneNumber,
join: state.data.join,
dateOfBirth: state.data.dateOfBirth,
zipCode: state.data.zipCode,
membershipNo: state.data.membershipNo,
}))
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
const completeStep = useStepsStore((state) => state.completeStep)
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
const join = useEnterDetailsStore((state) => state.guest.join)
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
const methods = useForm<DetailsSchema>({
defaultValues: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
lastName: user?.lastName ?? initialData.lastName,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
join: initialData.join,
dateOfBirth: initialData.dateOfBirth,
zipCode: initialData.zipCode,
membershipNo: initialData.membershipNo,
},
criteriaMode: "all",
mode: "all",
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
reValidateMode: "onChange",
values: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
dateOfBirth: initialData.dateOfBirth,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
zipCode: initialData.zipCode,
},
})
const onSubmit = useCallback(
(values: DetailsSchema) => {
updateDetails(values)
completeStep()
},
[completeStep, updateDetails]
[updateDetails]
)
return (

View File

@@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }),
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
membershipNo: z.string().optional(),
membershipNo: z.string().default(""),
})
)
@@ -50,13 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [
// 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().optional(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
phoneNumber: z.string().optional(),
countryCode: z.string().default(""),
email: z.string().default(""),
firstName: z.string().default(""),
lastName: z.string().default(""),
membershipNo: z.string().default(""),
phoneNumber: z.string().default(""),
join: z
.boolean()
.optional()
.transform((_) => false),
dateOfBirth: z.string().default(""),
zipCode: z.string().default(""),
})

View File

@@ -0,0 +1,32 @@
"use client"
import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./header.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onClick={() => openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })}
theme="base"
size="small"
variant="icon"
intent="textInverted"
wrapping
className={styles.toggle}
>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRight height="14" color="white" />
</Button>
)
}

View File

@@ -0,0 +1,53 @@
.header {
position: relative;
overflow: hidden;
}
.hero {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
object-fit: cover;
}
.wrapper {
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x2);
background-color: rgba(57, 57, 57, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--Spacing-x2);
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.address {
display: flex;
gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
.toggle {
padding: 0px !important;
}
@media (min-width: 768px) {
.wrapper {
padding: var(--Spacing-x3) var(--Spacing-x3);
}
}
@media screen and (min-width: 1367px) {
.wrapper {
padding: var(--Spacing-x6) var(--Spacing-x5);
}
}

View File

@@ -0,0 +1,53 @@
import Image from "@/components/Image"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import getSingleDecimal from "@/utils/numberFormatting"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./header.module.css"
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
export default async function HotelHeader({ hotelData }: HotelHeaderProps) {
const intl = await getIntl()
const hotel = hotelData.data.attributes
const image = hotel.hotelContent?.images
return (
<header className={styles.header}>
<Image
className={styles.hero}
alt={image.metaData.altText || image.metaData.altText_En || ""}
src={image.imageSizes.large}
height={200}
width={1196}
/>
<div className={styles.wrapper}>
<div className={styles.titleContainer}>
<Title as="h1" level="h1" color="white">
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="white">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<Caption color="white"></Caption>
<Caption color="white">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</address>
</div>
<ToggleSidePeek hotelId={hotel.operaId} />
</div>
</header>
)
}

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect } from "react"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
export default function HistoryStateManager() {
const setCurrentStep = useStepsStore((state) => state.setStep)
const currentStep = useStepsStore((state) => state.currentStep)
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const handleBackButton = useCallback(
(event: PopStateEvent) => {

View File

@@ -3,12 +3,12 @@
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/details"
import { detailsStorageName } from "@/stores/enter-details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsState } from "@/types/stores/details"
import type { DetailsState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
@@ -25,10 +25,10 @@ export default function PaymentCallback({
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
Pick<DetailsState, "booking">
> = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.data.booking,
detailsStorage.state.booking,
searchObject
)

View File

@@ -0,0 +1,425 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import {
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
const maxRetries = 4
const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
export default function PaymentClient({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
}))
const userData = useEnterDetailsStore((state) => state.guest)
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const availablePaymentOptions =
useAvailablePaymentOptions(otherPaymentOptions)
const [priceChangeData, setPriceChangeData] = useState<{
oldPrice: number
newPrice: number
} | null>()
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setConfirmationNumber(result.confirmationNumber)
if (result.metadata?.priceChangedMetadata) {
setPriceChangeData({
oldPrice: roomPrice.publicPrice,
newPrice: result.metadata.priceChangedMetadata.totalPrice,
})
} else {
setIsPollingForBookingStatus(true)
}
} else {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
},
onError: (error) => {
console.error("Error", error)
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
},
})
const priceChange = trpc.booking.priceChange.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setIsPollingForBookingStatus(true)
} else {
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
}
setPriceChangeData(null)
},
onError: (error) => {
console.error("Error", error)
setPriceChangeData(null)
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
} else if (bookingStatus.isTimeout) {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
}, [bookingStatus, router, intl])
useEffect(() => {
setIsSubmittingDisabled(
!methods.formState.isValid || methods.formState.isSubmitting
)
}, [
methods.formState.isValid,
methods.formState.isSubmitting,
setIsSubmittingDisabled,
])
const handleSubmit = useCallback(
(data: PaymentFormData) => {
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
const { toDate, fromDate, rooms, hotel } = booking
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
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`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
adults: room.adults,
childrenAges: room.children?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
},
[
breakfast,
bedType,
userData,
booking,
roomPrice,
savedCreditCards,
lang,
user,
initiateBooking,
]
)
if (
initiateBooking.isPending ||
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
) {
return <LoadingSpinner />
}
const guaranteeing = intl.formatMessage({ id: "guaranteeing" })
const paying = intl.formatMessage({ id: "paying" })
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
return (
<>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
id: "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}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
/>
))}
</div>
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
/>
))}
</div>
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage<React.ReactNode>(
{
id: "booking.terms",
},
{
paymentVerb,
termsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
<Checkbox name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</section>
<div className={styles.submitButton}>
<Button
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
{priceChangeData ? (
<PriceChangeDialog
isOpen={!!priceChangeData}
oldPrice={priceChangeData.oldPrice}
newPrice={priceChangeData.newPrice}
currency={totalPrice.local.currency}
onCancel={() => {
const allSearchParams = searchParams.size
? `?${searchParams.toString()}`
: ""
router.push(`${selectRate(lang)}${allSearchParams}`)
}}
onAccept={() => priceChange.mutate({ confirmationNumber })}
/>
) : null}
</>
)
}

View File

@@ -1,362 +1,27 @@
"use client"
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { Label as AriaLabel } from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import PaymentClient from "./PaymentClient"
import {
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useDetailsStore } from "@/stores/details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
const maxRetries = 4
const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
export default function Payment({
export default async function Payment({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
supportedCards,
}: PaymentProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
breakfast,
bedType,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
const { toDate, fromDate, rooms, hotel } = booking
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [availablePaymentOptions, setAvailablePaymentOptions] =
useState(otherPaymentOptions)
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
})
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setConfirmationNumber(result.confirmationNumber)
} else {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
},
onError: (error) => {
console.error("Error", error)
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval,
})
useEffect(() => {
if (window.ApplePaySession) {
setAvailablePaymentOptions(otherPaymentOptions)
} else {
setAvailablePaymentOptions(
otherPaymentOptions.filter(
(option) => option !== PaymentMethodEnum.applePay
)
)
}
}, [otherPaymentOptions, setAvailablePaymentOptions])
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
} else if (bookingStatus.isTimeout) {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
}, [bookingStatus, router, intl])
useEffect(() => {
setIsSubmittingDisabled(
!methods.formState.isValid || methods.formState.isSubmitting
)
}, [
methods.formState.isValid,
methods.formState.isSubmitting,
setIsSubmittingDisabled,
])
function handleSubmit(data: PaymentFormData) {
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
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`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
adults: room.adults,
childrenAges: room.children?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
title: "",
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
}
if (
initiateBooking.isPending ||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
) {
return <LoadingSpinner />
}
const guaranteeing = intl.formatMessage({ id: "guaranteeing" })
const paying = intl.formatMessage({ id: "paying" })
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
return (
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
id: "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}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
/>
))}
</div>
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
/>
))}
</div>
</section>
<section className={styles.section}>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
<AriaLabel className={styles.terms}>
<Checkbox name="termsAndConditions" />
<Caption>
{intl.formatMessage<React.ReactNode>(
{
id: "booking.terms",
},
{
paymentVerb,
termsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
</AriaLabel>
</section>
<div className={styles.submitButton}>
<Button
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
<PaymentClient
user={user}
roomPrice={roomPrice}
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
)
}

View File

@@ -0,0 +1,73 @@
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { InfoCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./priceChangeDialog.module.css"
import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
export default function PriceChangeDialog({
isOpen,
oldPrice,
newPrice,
currency,
onCancel,
onAccept,
}: PriceChangeDialogProps) {
const intl = useIntl()
const title = intl.formatMessage({ id: "The price has increased" })
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}>
<InfoCircleIcon height={48} width={48} color="burgundy" />
<Title
level="h1"
as="h3"
textAlign="center"
textTransform="regular"
>
{title}
</Title>
</div>
<Body textAlign="center">
{intl.formatMessage({
id: "The price has increased since you selected your room.",
})}
<br />
{intl.formatMessage({
id: "You can still book the room but you need to confirm that you accept the new price",
})}
<br />
<span className={styles.oldPrice}>
{intl.formatNumber(oldPrice, { style: "currency", currency })}
</span>{" "}
<strong className={styles.newPrice}>
{intl.formatNumber(newPrice, { style: "currency", currency })}
</strong>
</Body>
</header>
<footer className={styles.footer}>
<Button intent="secondary" onClick={onCancel}>
{intl.formatMessage({ id: "Cancel" })}
</Button>
<Button onClick={onAccept}>
{intl.formatMessage({ id: "Accept new price" })}
</Button>
</footer>
</Dialog>
</Modal>
</ModalOverlay>
)
}

View File

@@ -0,0 +1,85 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
align-items: center;
background: rgba(0, 0, 0, 0.5);
display: flex;
height: var(--visual-viewport-height);
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 100;
&[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-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) var(--Spacing-x4);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: center;
}
.footer {
display: flex;
justify-content: center;
gap: var(--Spacing-x2);
}
.oldPrice {
text-decoration: line-through;
}
.newPrice {
font-size: 1.2em;
}

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { useDetailsStore } from "@/stores/details"
import { useStepsStore } from "@/stores/steps"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -11,53 +10,50 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./sectionAccordion.module.css"
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import { StepEnum } from "@/types/enums/step"
export default function SectionAccordion({
children,
header,
label,
step,
children,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useStepsStore((state) => state.currentStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useDetailsStore((state) => state.isValid[step])
const navigate = useStepsStore((state) => state.navigate)
const stepData = useDetailsStore((state) => state.data)
const stepStoreKey = StepStoreKeys[step]
const isValid = useEnterDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
breakfast: state.breakfast,
}))
const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
useEffect(() => {
if (step === StepEnum.selectBed) {
const value = stepData.bedType
value && setTitle(value.description)
if (step === StepEnum.selectBed && bedType) {
setTitle(bedType.description)
}
// If breakfast step, check if an option has been selected
if (
step === StepEnum.breakfast &&
(stepData.breakfast || stepData.breakfast === false)
) {
const value = stepData.breakfast
if (value === false) {
setTitle(intl.formatMessage({ id: "No breakfast" }))
if (step === StepEnum.breakfast && breakfast !== undefined) {
if (breakfast === false) {
setTitle(noBreakfastTitle)
} else {
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
setTitle(breakfastTitle)
}
}
}, [stepData, stepStoreKey, step, intl])
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
useEffect(() => {
// We need to set the state on mount because of hydration errors
setIsComplete(isValid)
}, [isValid])
}, [isValid, setIsComplete])
useEffect(() => {
setIsOpen(currentStep === step)
}, [currentStep, step])
}, [currentStep, setIsOpen, step])
function onModify() {
navigate(step)

View File

@@ -48,8 +48,6 @@
}
.selection {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
grid-area: selection;
}

View File

@@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({
hotelId,

View File

@@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./selectedRoom.module.css"
import { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
export default function SelectedRoom({
hotelId,

View File

@@ -16,7 +16,7 @@
justify-content: space-between;
align-items: center;
grid-template-areas:
"title button"
"title title"
"description button";
}
@@ -25,14 +25,13 @@
}
.description {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
grid-area: description;
}
.button {
grid-area: button;
justify-self: flex-end;
align-self: flex-start;
}
.iconWrapper {

View File

@@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { detailsStorageName } from "@/stores/details"
import { detailsStorageName } from "@/stores/enter-details"
import useLang from "@/hooks/useLang"

View File

@@ -0,0 +1,97 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Summary from "@/components/HotelReservation/Summary"
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
import styles from "./summary.module.css"
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
breakfast: state.breakfast,
fromDate: state.booking.fromDate,
join: state.guest.join,
membershipNo: state.guest.membershipNo,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toDate: state.booking.toDate,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
}
}
export default function ClientSummary({
adults,
cancellationText,
isMember,
kids,
memberRate,
rateDetails,
roomType,
}: ClientSummaryProps) {
const {
bedType,
breakfast,
fromDate,
join,
membershipNo,
packages,
roomPrice,
toDate,
toggleSummaryOpen,
totalPrice,
} = useEnterDetailsStore(storeSelector)
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
const room = {
adults,
cancellationText,
children: kids,
packages,
rateDetails,
roomPrice,
roomType,
}
return (
<>
<div className={styles.mobileSummary}>
<SummaryBottomSheet>
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
toggleSummaryOpen={toggleSummaryOpen}
totalPrice={totalPrice}
/>
</div>
</SummaryBottomSheet>
</div>
<div className={styles.desktopSummary}>
<div className={styles.hider} />
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
totalPrice={totalPrice}
/>
</div>
<div className={styles.shadow} />
</div>
</>
)
}

View File

@@ -1,325 +1,57 @@
"use client"
import { redirect } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { dt } from "@/lib/dt"
import { useDetailsStore } from "@/stores/details"
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getLang } from "@/i18n/serverContext"
import { ArrowRightIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Popover from "@/components/TempDesignSystem/Popover"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import ClientSummary from "./Client"
import styles from "./summary.module.css"
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/details"
export default async function Summary({
adults,
fromDate,
hotelId,
kids,
packageCodes,
rateCode,
roomTypeCode,
toDate,
}: SummaryPageProps) {
const lang = getLang()
function storeSelector(state: DetailsState) {
return {
fromDate: state.data.booking.fromDate,
toDate: state.data.booking.toDate,
bedType: state.data.bedType,
breakfast: state.data.breakfast,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
join: state.data.join,
membershipNo: state.data.membershipNo,
const availability = await getSelectedRoomAvailability({
adults,
children: kids ? generateChildrenString(kids) : undefined,
hotelId,
packageCodes,
rateCode,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
roomTypeCode,
})
const user = await getProfileSafely()
if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
redirect(selectRate(lang))
}
}
export default function Summary({ showMemberPrice, room }: SummaryProps) {
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | false
>()
const intl = useIntl()
const lang = useLang()
const {
bedType,
breakfast,
fromDate,
setTotalPrice,
toDate,
toggleSummaryOpen,
totalPrice,
join,
membershipNo,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
const nights = intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: diff }
)
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
const [price, setPrice] = useState(room.prices.public)
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
acc.local = acc.local + parseInt(curr.localPrice.totalPrice)
acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice)
return acc
},
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
const roomsPriceEuro = price.euro
? price.euro.amount + additionalPackageCost.euro
: undefined
useEffect(() => {
if (showMemberPrice || join || membershipNo) {
color.current = "red"
if (room.prices.member) {
setPrice(room.prices.member)
}
} else {
color.current = "uiTextHighContrast"
setPrice(room.prices.public)
}
}, [showMemberPrice, join, membershipNo, room.prices])
useEffect(() => {
setChosenBed(bedType)
if (breakfast || breakfast === false) {
setChosenBreakfast(breakfast)
if (breakfast === false) {
setTotalPrice({
local: {
amount: roomsPriceLocal,
currency: price.local.currency,
},
euro:
price.euro && roomsPriceEuro
? {
amount: roomsPriceEuro,
currency: price.euro.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: price.local.currency,
},
euro:
price.euro && roomsPriceEuro
? {
amount:
roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice),
currency: price.euro.currency,
}
: undefined,
})
}
}
}, [
bedType,
breakfast,
roomsPriceLocal,
price.local.currency,
price.euro,
roomsPriceEuro,
setTotalPrice,
])
return (
<section className={styles.summary}>
<header className={styles.header}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({ id: "Summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
size="small"
className={styles.chevronButton}
onClick={toggleSummaryOpen}
>
<ChevronDown height="20" width="20" />
</Button>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color.current}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(price.local.amount),
currency: price.local.currency,
}
)}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: room.adults }
)}
</Caption>
{room.children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: room.children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Popover
placement="bottom left"
triggerContent={
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
}
>
<aside className={styles.rateDetailsPopover}>
<header>
<Caption type="bold">{room.cancellationText}</Caption>
</header>
{room.rateDetails?.map((detail, idx) => (
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
))}
</aside>
</Popover>
</div>
{room.packages
? room.packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<Body color="uiTextHighContrast">
{roomPackage.description}
</Body>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
</Caption>
</div>
))
: null}
{chosenBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
) : null}
{chosenBreakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
) : chosenBreakfast?.code ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.localPrice.totalPrice,
currency: chosenBreakfast.localPrice.currency,
}
)}
</Caption>
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Link color="burgundy" href="#" variant="underscored" size="small">
{intl.formatMessage({ id: "Price details" })}
</Link>
</div>
<div>
{totalPrice.local.amount > 0 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Body>
)}
{totalPrice.euro && totalPrice.euro.amount > 0 && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.amount),
currency: totalPrice.euro.currency,
}
)}
</Caption>
)}
</div>
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
</section>
<ClientSummary
adults={adults}
cancellationText={availability.cancellationText}
isMember={!!user}
kids={kids}
memberRate={availability.memberRate}
rateDetails={availability.rateDetails}
roomType={availability.selectedRoom.roomType}
/>
)
}

View File

@@ -1,83 +1,68 @@
.mobileSummary {
display: block;
}
.desktopSummary {
display: none;
}
.summary {
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
.header {
display: grid;
grid-template-areas: "title button" "date button";
.hider {
display: none;
}
.title {
grid-area: title;
}
.chevronButton {
grid-area: button;
justify-self: end;
align-items: center;
margin-right: calc(0px - var(--Spacing-x2));
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
.shadow {
display: none;
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.chevronButton {
.mobileSummary {
display: none;
}
.desktopSummary {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
z-index: 9;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
margin-top: calc(0px - var(--Spacing-x9));
}
.shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-bottom: none;
}
.hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
top: calc(var(--booking-widget-desktop-height) - 6px);
margin-top: var(--Spacing-x4);
height: 40px;
}
}

View File

@@ -5,18 +5,6 @@
margin: 0;
width: 100%;
}
.noRooms {
display: flex;
gap: var(--Spacing-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.divider {
margin: var(--Spacing-x-half) 0;
}
@@ -37,9 +25,3 @@
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}
@media screen and (min-width: 1367px) {
.prices {
width: 260px;
}
}

View File

@@ -5,7 +5,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../hotelPriceList.module.css"
import styles from "./hotelPriceCard.module.css"
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"

View File

@@ -1,56 +0,0 @@
import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { ErrorCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import styles from "./hotelPriceList.module.css"
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
export default function HotelPriceList({
price,
hotelId,
}: HotelPriceListProps) {
const intl = useIntl()
const params = useParams()
const lang = params.lang as Lang
return (
<div className={styles.prices}>
{price ? (
<>
{price.public && <HotelPriceCard productTypePrices={price.public} />}
{price.member && (
<HotelPriceCard productTypePrices={price.member} isMemberPrice />
)}
<Button
asChild
theme="base"
intent="primary"
size="small"
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${hotelId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</>
) : (
<NoPriceAvailableCard />
)}
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "../hotelPriceList.module.css"
import styles from "./noPriceAvailable.module.css"
export default function NoPriceAvailableCard() {
const intl = useIntl()

View File

@@ -0,0 +1,12 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
}
.noRooms {
display: flex;
gap: var(--Spacing-x1);
}

View File

@@ -70,10 +70,6 @@
gap: var(--Spacing-x-half);
}
.detailsButton {
border-bottom: none;
}
.button {
min-width: 160px;
}
@@ -84,6 +80,12 @@
gap: var(--Spacing-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
@media screen and (min-width: 1367px) {
.card.pageListing {
flex-direction: row;
@@ -133,4 +135,8 @@
.pageListing .address {
display: inline;
}
.pageListing .prices {
width: 260px;
}
}

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